diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 8a723952..2a10388e 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -51,6 +51,63 @@ jobs: target: /home/ubuntu/deploy overwrite: true + - name: Copy Nginx Config to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USERNAME }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + source: infra/nginx/conf.d/upload-limits.conf + target: /home/ubuntu/deploy + overwrite: true + + # nginx 설정이 서버에만 있으면 인스턴스를 새로 띄울 때 기본값으로 돌아간다. + # 리포의 파일을 매 배포마다 반영해서 서버 상태가 코드와 갈라지지 않게 한다. + - name: Apply Nginx Config + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USERNAME }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + script: | + set -eu + + SOURCE="/home/ubuntu/deploy/infra/nginx/conf.d/upload-limits.conf" + TARGET="/etc/nginx/conf.d/upload-limits.conf" + # conf.d 안에 두면 include 대상이 될 수 있어 밖에 백업한다. + BACKUP="/tmp/upload-limits.conf.bak" + + if sudo cmp -s "$SOURCE" "$TARGET"; then + echo "nginx 설정 변경 없음. reload 를 건너뜁니다." + exit 0 + fi + + # 검사에 실패했을 때 지워버리면, 실행 중인 nginx 는 멀쩡해도 다음 reload 때 + # 한도 설정이 통째로 사라진다. 되돌릴 수 있게 원본을 먼저 남긴다. + if sudo test -e "$TARGET"; then + sudo cp "$TARGET" "$BACKUP" + HAD_TARGET=1 + else + HAD_TARGET=0 + fi + + sudo cp "$SOURCE" "$TARGET" + + # 문법 검사를 통과할 때만 reload 한다. 실패하면 실행 중인 nginx 는 그대로 둔다. + if ! sudo nginx -t; then + echo "nginx 설정 검사 실패. 이전 설정으로 되돌립니다." + if [ "$HAD_TARGET" -eq 1 ]; then + sudo mv "$BACKUP" "$TARGET" + else + sudo rm -f "$TARGET" + fi + exit 1 + fi + + sudo rm -f "$BACKUP" + sudo systemctl reload nginx + echo "nginx 설정 반영 완료" + - name: Deploy Docker Container uses: appleboy/ssh-action@v1 with: diff --git a/infra/nginx/conf.d/upload-limits.conf b/infra/nginx/conf.d/upload-limits.conf new file mode 100644 index 00000000..020c3e23 --- /dev/null +++ b/infra/nginx/conf.d/upload-limits.conf @@ -0,0 +1,18 @@ +# nginx 업로드 제한. /etc/nginx/conf.d/ 에 놓이며 http 블록 안에서 include 된다. +# +# 이 파일이 없으면 nginx 는 client_max_body_size 기본값 1MB 로 동작한다. +# 앱과 프론트가 100MB 로 맞춰져 있어도 1MB 를 넘는 요청은 nginx 가 413 으로 끊어 +# 스프링에 닿지도 않는다. 그래서 서비스 코드의 검증 로직은 실행조차 되지 않고, +# 응답도 공통 래퍼가 아닌 nginx 기본 HTML 이라 프론트가 원인을 표시하지 못한다. +# +# certbot 이 관리하는 server 블록은 건드리지 않는다. +# 여기에 두면 http 레벨에 적용되어 모든 server/location 으로 상속된다. + +# 앱의 spring.servlet.multipart.max-request-size(105MB)와 맞춘다. +# 파일 본문에 JSON 파트와 멀티파트 경계가 더해지므로 개별 파일 한도(100MB)보다 커야 한다. +client_max_body_size 105M; + +# 큰 파일은 앱이 S3 업로드를 마친 뒤에야 응답한다. +# 기본값 60초로는 100MB 근처에서 504 가 난다. 파일은 올라갔는데 실패로 보이는 상태가 된다. +proxy_read_timeout 300s; +proxy_send_timeout 300s; diff --git a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java index 595f50a8..5da8ffc7 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java @@ -8,6 +8,7 @@ import com.slatto.domain.feedback.dto.request.FeedbackRequest.FeedbackStatusReqDTO; import com.slatto.domain.feedback.dto.response.FeedbackResponse.FeedbackStatusResDTO; import com.slatto.domain.feedback.service.FeedbackService; +import com.slatto.global.config.OptionalAuthentication; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -28,6 +29,7 @@ public class FeedbackController { private final FeedbackService feedbackService; @Operation(summary = "피드백 작성") + @OptionalAuthentication @PostMapping("/videos/{videoId}/feedbacks") public ResponseEntity> createFeedback( @PathVariable Long videoId, @@ -42,6 +44,7 @@ public ResponseEntity> createFeedback( } @Operation(summary = "피드백 수정") + @OptionalAuthentication @PatchMapping("/feedbacks/{feedbackId}") public ResponseEntity> updateFeedback( @PathVariable Long feedbackId, @@ -55,6 +58,7 @@ public ResponseEntity> updateFeedback( } @Operation(summary = "피드백 삭제") + @OptionalAuthentication @DeleteMapping("/feedbacks/{feedbackId}") public ResponseEntity> deleteFeedback( @PathVariable Long feedbackId, @@ -68,6 +72,7 @@ public ResponseEntity> deleteFeedback( } @Operation(summary = "피드백 목록 조회") + @OptionalAuthentication @GetMapping("/videos/{videoId}/feedbacks") public ResponseEntity> getFeedbackList( @PathVariable Long videoId, @@ -82,7 +87,10 @@ public ResponseEntity> getFeedbackList( .ok(ApiResponse.success(CommonSuccessCode.OK, result)); } - @Operation(summary = "피드백 해결 상태 변경") + @Operation( + summary = "피드백 해결 상태 변경", + description = "활성 프로젝트 멤버만 변경할 수 있다. 다른 피드백 API 와 달리 게스트는 호출할 수 없다." + ) @PatchMapping("/feedbacks/{feedbackId}/status") public ResponseEntity> changeFeedbackStatus( @PathVariable Long feedbackId, diff --git a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java index a8dd2df5..ed8a4656 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java @@ -8,6 +8,7 @@ import com.slatto.domain.feedback.dto.request.FeedbackDetailRequest.ReplyStatusReqDTO; import com.slatto.domain.feedback.dto.response.FeedbackDetailResponse.ReplyStatusResDTO; import com.slatto.domain.feedback.service.FeedbackDetailService; +import com.slatto.global.config.OptionalAuthentication; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -28,6 +29,7 @@ public class FeedbackDetailController { private final FeedbackDetailService feedbackDetailService; @Operation(summary = "답글 작성") + @OptionalAuthentication @PostMapping("/feedbacks/{feedbackId}/replies") public ResponseEntity> createReply( @PathVariable Long feedbackId, @@ -43,6 +45,7 @@ public ResponseEntity> createReply( } @Operation(summary = "답글 목록 조회") + @OptionalAuthentication @GetMapping("/feedbacks/{feedbackId}/replies") public ResponseEntity> getReplyList( @PathVariable Long feedbackId, @@ -59,6 +62,7 @@ public ResponseEntity> getReplyList( } @Operation(summary = "답글 수정") + @OptionalAuthentication @PatchMapping("/replies/{replyId}") public ResponseEntity> updateReply( @PathVariable Long replyId, @@ -73,6 +77,7 @@ public ResponseEntity> updateReply( } @Operation(summary = "답글 삭제") + @OptionalAuthentication @DeleteMapping("/replies/{replyId}") public ResponseEntity> deleteReply( @PathVariable Long replyId, @@ -86,7 +91,10 @@ public ResponseEntity> deleteReply( .ok(ApiResponse.success(CommonSuccessCode.OK, null)); } - @Operation(summary = "답글 해결 상태 변경") + @Operation( + summary = "답글 해결 상태 변경", + description = "활성 프로젝트 멤버만 변경할 수 있다. 다른 답글 API 와 달리 게스트는 호출할 수 없다." + ) @PatchMapping("/replies/{replyId}/status") public ResponseEntity> changeReplyStatus( @PathVariable Long replyId, diff --git a/src/main/java/com/slatto/domain/feedback/dto/response/FeedbackResponse.java b/src/main/java/com/slatto/domain/feedback/dto/response/FeedbackResponse.java index a4a598f4..832a9b86 100644 --- a/src/main/java/com/slatto/domain/feedback/dto/response/FeedbackResponse.java +++ b/src/main/java/com/slatto/domain/feedback/dto/response/FeedbackResponse.java @@ -14,7 +14,7 @@ public class FeedbackResponse { public record ActorDTO( @Schema(example = "USER") String type, // "USER" 또는 "GUEST" @Schema(example = "20") Long id, - @Schema(example = "김수민") String name + @Schema(example = "차태훈") String name ) { // 회원이면 USER actor 만들기 public static ActorDTO fromUser(Users user) { diff --git a/src/main/java/com/slatto/domain/notification/controller/NotificationController.java b/src/main/java/com/slatto/domain/notification/controller/NotificationController.java index 4f9ca8f5..a5f8bd2c 100644 --- a/src/main/java/com/slatto/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/slatto/domain/notification/controller/NotificationController.java @@ -23,7 +23,12 @@ public class NotificationController { private final NotificationService notificationService; - @Operation(summary = "알림 목록 조회") + @Operation( + summary = "알림 목록 조회", + description = """ + 최근 24시간 이내에 갱신된 알림만 반환한다. + 읽지 않은 알림이 먼저 오고, 그 다음은 최근에 갱신된 순이다. cursor 기반 페이지네이션이며 size 는 기본 20이다.""" + ) @GetMapping public ApiResponse getNotifications( @AuthenticationPrincipal Long currentUserId, @@ -39,7 +44,10 @@ public ApiResponse getNotifications( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "알림 단건 읽음 처리") + @Operation( + summary = "알림 단건 읽음 처리", + description = "본인에게 온 알림만 처리할 수 있다." + ) @PatchMapping("/{notificationId}/read") public ApiResponse markNotificationAsRead( @AuthenticationPrincipal Long currentUserId, @@ -50,7 +58,12 @@ public ApiResponse markNotificationAsRead( return ApiResponse.success(CommonSuccessCode.OK, null); } - @Operation(summary = "알림 전체 읽음 처리") + @Operation( + summary = "알림 전체 읽음 처리", + description = """ + 읽지 않은 알림을 모두 읽음으로 바꾼다. + 목록 조회와 달리 24시간 제한이 없어서, 목록에 보이지 않는 오래된 알림도 함께 처리된다.""" + ) @PatchMapping("/read-all") public ApiResponse markAllNotificationsAsRead( @AuthenticationPrincipal Long currentUserId diff --git a/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java b/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java index eba48bb1..aa376a91 100644 --- a/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java +++ b/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java @@ -23,7 +23,12 @@ public class RecentActivityController { private final RecentActivityService recentActivityService; - @Operation(summary = "프로젝트 최근활동 목록 조회") + @Operation( + summary = "프로젝트 최근활동 목록 조회", + description = """ + 프로젝트 멤버만 조회할 수 있다. 각 항목에 내가 읽었는지 여부가 함께 담긴다. + cursor 는 응답의 nextCursor 를 그대로 넘기는 문자열이며 size 는 기본 20, 최대 50이다.""" + ) @GetMapping public ApiResponse getRecentActivities( @AuthenticationPrincipal Long currentUserId, @@ -41,7 +46,10 @@ public ApiResponse getRecentActivities( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 최근활동 단건 읽음 처리") + @Operation( + summary = "프로젝트 최근활동 단건 읽음 처리", + description = "읽음은 호출한 사람에게만 기록된다. 이미 읽은 활동에 다시 호출해도 실패하지 않는다." + ) @PatchMapping("/{activityId}/read") public ApiResponse markActivityAsRead( @AuthenticationPrincipal Long currentUserId, @@ -53,7 +61,10 @@ public ApiResponse markActivityAsRead( return ApiResponse.success(CommonSuccessCode.OK, null); } - @Operation(summary = "프로젝트 최근활동 전체 읽음 처리") + @Operation( + summary = "프로젝트 최근활동 전체 읽음 처리", + description = "해당 프로젝트의 활동만 읽음 처리한다. 다른 프로젝트에는 영향이 없다." + ) @PatchMapping("/read-all") public ApiResponse markAllActivitiesAsRead( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectController.java b/src/main/java/com/slatto/domain/project/controller/ProjectController.java index a858c44e..5369c35c 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -35,7 +35,12 @@ public class ProjectController { private final ProjectService projectService; - @Operation(summary = "프로젝트 목록 조회") + @Operation( + summary = "프로젝트 목록 조회", + description = """ + 내가 참여 중인 프로젝트만 반환한다. 내가 고정한 프로젝트가 먼저 오고, 나머지는 최근에 만들어진 순이다. + cursor 기반 페이지네이션이며 size 는 기본 20, 최대 50이다.""" + ) @GetMapping public ApiResponse getProjects( @AuthenticationPrincipal Long currentUserId, @@ -48,7 +53,12 @@ public ApiResponse getProjects( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 생성") + @Operation( + summary = "프로젝트 생성", + description = """ + 만든 사람이 ADMIN 역할의 멤버로 함께 등록된다. + 한 사람이 가질 수 있는 프로젝트는 5개까지이며, 삭제한 프로젝트는 개수에 포함되지 않는다.""" + ) @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createProject( @@ -60,7 +70,10 @@ public ApiResponse createProject( return ApiResponse.success(CommonSuccessCode.CREATED, response); } - @Operation(summary = "프로젝트 상세 조회") + @Operation( + summary = "프로젝트 상세 조회", + description = "프로젝트 멤버만 조회할 수 있다." + ) @GetMapping("/{projectId}") public ApiResponse getProject( @AuthenticationPrincipal Long currentUserId, @@ -74,6 +87,9 @@ public ApiResponse getProject( @Operation( summary = "프로젝트 수정", description = """ + ADMIN 만 수정할 수 있다. + 보내지 않은 필드는 null 로 덮어써지므로, 바꾸지 않을 값도 함께 보내야 한다. + `status` 를 `COMPLETED` 로 바꾸면 참여 중인 멤버 전원의 포트폴리오에 이 프로젝트가 생성된다. 프로젝트명·유형·개인외주 구분·설명·기간이 그대로 옮겨가고, 각자 맡은 역할이 함께 채워진다. 생성된 뒤에는 본인이 프로필에서 수정·삭제할 수 있다. @@ -99,7 +115,10 @@ public ApiResponse updateProject( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 삭제") + @Operation( + summary = "프로젝트 삭제", + description = "ADMIN 만 삭제할 수 있다. 실제로 지우지 않고 삭제 표시만 남긴다." + ) @DeleteMapping("/{projectId}") public ApiResponse deleteProject( @AuthenticationPrincipal Long currentUserId, @@ -110,7 +129,12 @@ public ApiResponse deleteProject( return ApiResponse.success(CommonSuccessCode.OK, null); } - @Operation(summary = "프로젝트 고정") + @Operation( + summary = "프로젝트 고정", + description = """ + 고정은 호출한 사람에게만 적용되며 다른 멤버의 목록 순서에는 영향을 주지 않는다. + 이미 고정한 프로젝트에 다시 호출해도 실패하지 않는다.""" + ) @PostMapping("/{projectId}/pin") public ApiResponse pinProject( @AuthenticationPrincipal Long currentUserId, @@ -121,7 +145,10 @@ public ApiResponse pinProject( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 고정 해제") + @Operation( + summary = "프로젝트 고정 해제", + description = "고정하지 않은 프로젝트에 호출해도 실패하지 않는다." + ) @DeleteMapping("/{projectId}/pin") public ApiResponse unpinProject( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java index 3a854f49..0915d374 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -42,7 +42,12 @@ public class ProjectFileController { private final ProjectFileService projectFileService; - @Operation(summary = "프로젝트 파일 목록 조회") + @Operation( + summary = "프로젝트 파일 목록 조회", + description = """ + 고정된 파일이 먼저 오고, keyword 로 파일명을 검색할 수 있다. + cursor 기반 페이지네이션이며 size 는 기본 20, 최대 50이다.""" + ) @GetMapping public ApiResponse getProjectFiles( @AuthenticationPrincipal Long currentUserId, @@ -62,7 +67,12 @@ public ApiResponse getProjectFiles( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 파일 업로드") + @Operation( + summary = "프로젝트 파일 업로드", + description = """ + multipart/form-data 로 보낸다. 최대 100MB 이며 pdf, jpg, jpeg, png, doc, docx 만 허용한다. + 확장자와 Content-Type 이 서로 맞지 않으면 거부한다. 업로드하면 다른 멤버에게 알림이 간다.""" + ) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) public ApiResponse uploadProjectFile( @@ -81,7 +91,10 @@ public ApiResponse uploadProjectFile( return ApiResponse.success(CommonSuccessCode.CREATED, response); } - @Operation(summary = "프로젝트 파일 수정") + @Operation( + summary = "프로젝트 파일 수정", + description = "업로더 본인 또는 ADMIN 만 수정할 수 있다. 보내지 않은 필드는 기존 값이 유지된다." + ) @PatchMapping("/{fileId}") public ApiResponse updateProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -99,7 +112,12 @@ public ApiResponse updateProjectFile( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 파일 삭제") + @Operation( + summary = "프로젝트 파일 삭제", + description = """ + 업로더 본인 또는 ADMIN 만 삭제할 수 있다. + 삭제 표시만 남기며 저장소의 파일 자체는 지우지 않는다.""" + ) @DeleteMapping("/{fileId}") public ApiResponse deleteProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -111,7 +129,12 @@ public ApiResponse deleteProjectFile( return ApiResponse.success(CommonSuccessCode.OK, null); } - @Operation(summary = "프로젝트 파일 고정") + @Operation( + summary = "프로젝트 파일 고정", + description = """ + 파일 고정은 프로젝트 멤버 모두에게 함께 보인다. 개인별로 적용되는 프로젝트 고정과 다르다. + 업로더 본인 또는 ADMIN 만 할 수 있다.""" + ) @PostMapping("/{fileId}/pin") public ApiResponse pinProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -127,7 +150,10 @@ public ApiResponse pinProjectFile( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 파일 고정 해제") + @Operation( + summary = "프로젝트 파일 고정 해제", + description = "업로더 본인 또는 ADMIN 만 할 수 있다." + ) @DeleteMapping("/{fileId}/pin") public ApiResponse unpinProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -143,7 +169,10 @@ public ApiResponse unpinProjectFile( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 파일 다운로드") + @Operation( + summary = "프로젝트 파일 다운로드", + description = "공통 응답 래퍼가 아니라 파일 본문을 그대로 반환한다. Content-Disposition 이 attachment 로 내려간다." + ) @GetMapping("/{fileId}/download") public ResponseEntity downloadProjectFile( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java b/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java index 76c64998..60000796 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java @@ -9,6 +9,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -30,7 +31,13 @@ public class ProjectInvitationController { private final ProjectInvitationService projectInvitationService; - @Operation(summary = "프로젝트 초대 링크 생성") + @Operation( + summary = "프로젝트 초대 링크 생성", + description = """ + ADMIN 만 만들 수 있다. expirationPeriod 로 유효기간을 정하며 기본값은 72시간이다. + 원본 토큰은 응답의 inviteUrl 에만 담기고 서버에는 해시로 저장된다. + 응답을 잃으면 서버에서 원본 토큰을 되찾거나 같은 링크를 다시 받을 수 없고, 새로 만들어야 한다.""" + ) @PostMapping("/projects/{projectId}/invitations") @ResponseStatus(HttpStatus.CREATED) public ApiResponse createInvitation( @@ -47,7 +54,13 @@ public ApiResponse createInvitation( return ApiResponse.success(CommonSuccessCode.CREATED, response); } - @Operation(summary = "프로젝트 초대 링크 정보 조회") + @Operation( + summary = "프로젝트 초대 링크 정보 조회", + description = """ + 링크를 받은 사람이 로그인 전에 어떤 프로젝트인지 확인하는 용도다. + status 로 PENDING, ACCEPTED, EXPIRED 를 구분한다.""" + ) + @SecurityRequirements @GetMapping("/project-invitations/{token}") public ApiResponse getInvitation( @PathVariable String token @@ -57,7 +70,12 @@ public ApiResponse getInvitation( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 초대 수락") + @Operation( + summary = "프로젝트 초대 수락", + description = """ + 수락할 때 맡을 역할을 함께 보낸다. + 한 번 수락한 링크는 다시 쓸 수 없고, 기간이 지났거나 이미 멤버인 경우에도 실패한다.""" + ) @PostMapping("/project-invitations/{token}/accept") public ApiResponse acceptInvitation( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java index fc8f218e..091adec0 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java @@ -27,7 +27,10 @@ public class ProjectMemberController { private final ProjectMemberService projectMemberService; - @Operation(summary = "프로젝트 멤버 목록 조회") + @Operation( + summary = "프로젝트 멤버 목록 조회", + description = "페이지네이션 없이 전체를 반환한다. 프로젝트를 나간 멤버는 제외된다." + ) @GetMapping public ApiResponse getProjectMembers( @AuthenticationPrincipal Long currentUserId, @@ -41,7 +44,10 @@ public ApiResponse getProjectMembers( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 나가기") + @Operation( + summary = "프로젝트 나가기", + description = "ADMIN 은 나갈 수 없다. 나가면 멤버 목록에서 빠지지만 작성한 글과 파일은 남는다." + ) @DeleteMapping("/me") public ApiResponse leaveProject( @AuthenticationPrincipal Long currentUserId, @@ -52,7 +58,10 @@ public ApiResponse leaveProject( return ApiResponse.success(CommonSuccessCode.OK, null); } - @Operation(summary = "프로젝트 멤버 상세 조회") + @Operation( + summary = "프로젝트 멤버 상세 조회", + description = "경로의 memberId 는 사용자 ID 가 아니라 프로젝트 멤버 ID 다." + ) @GetMapping("/{memberId}") public ApiResponse getProjectMember( @AuthenticationPrincipal Long currentUserId, @@ -68,7 +77,12 @@ public ApiResponse getProjectMember( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 멤버 역할 수정") + @Operation( + summary = "프로젝트 멤버 역할 수정", + description = """ + ADMIN 이거나 본인의 역할일 때만 수정할 수 있다. + 보낸 역할 목록으로 전체를 교체하므로, 유지할 역할도 함께 보내야 한다.""" + ) @PatchMapping("/{memberId}") public ApiResponse updateProjectMemberRoles( @AuthenticationPrincipal Long currentUserId, @@ -86,7 +100,12 @@ public ApiResponse updateProjectMemberRoles( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 멤버 삭제") + @Operation( + summary = "프로젝트 멤버 삭제", + description = """ + ADMIN 만 다른 멤버를 내보낼 수 있다. + 자기 자신은 이 API 로 내보낼 수 없고 나가기를 써야 한다.""" + ) @DeleteMapping("/{memberId}") public ApiResponse removeProjectMember( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java index b01cd809..8200ece0 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java @@ -33,7 +33,12 @@ public class ProjectNoticeController { private final ProjectNoticeService projectNoticeService; - @Operation(summary = "프로젝트 공지 목록 조회") + @Operation( + summary = "프로젝트 공지 목록 조회", + description = """ + 각 항목에 내가 읽었는지 여부가 함께 담긴다. + cursor 기반 페이지네이션이며 size 는 기본 20, 최대 50이다.""" + ) @GetMapping public ApiResponse getProjectNotices( @AuthenticationPrincipal Long currentUserId, @@ -51,7 +56,10 @@ public ApiResponse getProjectNotices( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 공지 상세 조회") + @Operation( + summary = "프로젝트 공지 상세 조회", + description = "조회만으로는 읽음 처리되지 않는다. 읽음 처리는 별도 엔드포인트를 호출해야 한다." + ) @GetMapping("/{noticeId}") public ApiResponse getProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -67,7 +75,12 @@ public ApiResponse getProjectNotice( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 공지 등록") + @Operation( + summary = "프로젝트 공지 등록", + description = """ + 프로젝트 멤버면 누구나 등록할 수 있다. + 작성자 본인은 처음부터 읽음 상태이며, 나머지 멤버에게는 알림이 간다.""" + ) @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createProjectNotice( @@ -84,7 +97,10 @@ public ApiResponse createProjectNotice( return ApiResponse.success(CommonSuccessCode.CREATED, response); } - @Operation(summary = "프로젝트 공지 수정") + @Operation( + summary = "프로젝트 공지 수정", + description = "작성자 본인 또는 ADMIN 만 수정할 수 있다. 제목과 내용을 모두 덮어쓴다." + ) @PatchMapping("/{noticeId}") public ApiResponse updateProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -102,7 +118,10 @@ public ApiResponse updateProjectNotice( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로젝트 공지 삭제") + @Operation( + summary = "프로젝트 공지 삭제", + description = "작성자 본인 또는 ADMIN 만 삭제할 수 있다. 삭제 표시만 남긴다." + ) @DeleteMapping("/{noticeId}") public ApiResponse deleteProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -114,7 +133,12 @@ public ApiResponse deleteProjectNotice( return ApiResponse.success(CommonSuccessCode.OK, null); } - @Operation(summary = "프로젝트 공지 읽음 처리") + @Operation( + summary = "프로젝트 공지 읽음 처리", + description = """ + 읽음은 호출한 사람에게만 기록된다. + 이미 읽은 공지에 다시 호출해도 실패하지 않고, 읽은 시각만 갱신된다.""" + ) @PatchMapping("/{noticeId}/read") public ApiResponse readProjectNotice( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java index 11e26ca2..33f8709d 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java @@ -124,7 +124,12 @@ public ApiResponse getRecommendedRecruitments return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "구인구직 공고 상세 조회") + @Operation( + summary = "구인구직 공고 상세 조회", + description = """ + 조회할 때마다 조회수가 1 올라가며, 응답의 viewCount 는 이번 조회가 반영된 값이다. + 본인이 쓴 공고는 조회수가 오르지 않는다.""" + ) @GetMapping("/{recruitmentId}") public ApiResponse getRecruitment( @AuthenticationPrincipal Long currentUserId, @@ -163,7 +168,10 @@ public ApiResponse updateRecruitment( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "구인구직 공고 삭제") + @Operation( + summary = "구인구직 공고 삭제", + description = "작성자 본인만 삭제할 수 있다. 삭제 표시만 남긴다." + ) @DeleteMapping("/{recruitmentId}") public ApiResponse deleteRecruitment( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/schedule/dto/ScheduleDailyResponse.java b/src/main/java/com/slatto/domain/schedule/dto/ScheduleDailyResponse.java index 1d071e57..2b81e725 100644 --- a/src/main/java/com/slatto/domain/schedule/dto/ScheduleDailyResponse.java +++ b/src/main/java/com/slatto/domain/schedule/dto/ScheduleDailyResponse.java @@ -52,7 +52,7 @@ public static class DailySchedule { @Schema(description = "프로젝트 일정 대상자 목록. 개인 일정이면 빈 배열입니다.") private List participants; - @Schema(description = "일정 대상자 요약 문구", example = "김수민 외 1명") + @Schema(description = "일정 대상자 요약 문구", example = "그린 외 1명") private String participantSummary; @Schema(description = "공용 메모", example = "레퍼런스 무드보드 잡기, 1차 검토하기", nullable = true) @@ -73,7 +73,7 @@ public static class Participant { @Schema(description = "사용자 ID", example = "1") private Long userId; - @Schema(description = "닉네임", example = "김수민") + @Schema(description = "닉네임", example = "그린") private String nickname; @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.png", nullable = true) diff --git a/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java b/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java index b31df128..03e2d238 100644 --- a/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java +++ b/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java @@ -12,6 +12,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -41,6 +42,7 @@ public ApiResponse createShareLink( } @Operation(summary = "공유 링크 진입 검증", description = "게스트가 링크로 접근했을 때 유효성을 확인합니다. 인증이 필요 없습니다.") + @SecurityRequirements @GetMapping("/share-links/{token}") public ApiResponse getShareLinkByToken( @PathVariable String token @@ -53,6 +55,7 @@ public ApiResponse getShareLinkByToken( @Operation(summary = "게스트 등록", description = "링크로 진입한 게스트가 이름을 등록하고 guestId를 발급받습니다. 인증이 필요 없습니다.") @ResponseStatus(HttpStatus.CREATED) + @SecurityRequirements @PostMapping("/share-links/{token}/guests") public ApiResponse registerGuest( @PathVariable String token, diff --git a/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java b/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java index 7667b184..62775f93 100644 --- a/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java +++ b/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java @@ -184,7 +184,7 @@ public static VideoReferenceFileItemResDTO from(VideoReferenceFile referenceFile @Schema(description = "영상 참조 파일 업로더 정보") public record VideoReferenceFileUploaderResDTO( @Schema(example = "1") Long id, - @Schema(example = "김수민") String nickname + @Schema(example = "그린") String nickname ) { public static VideoReferenceFileUploaderResDTO from(ProjectFile projectFile) { return new VideoReferenceFileUploaderResDTO( diff --git a/src/main/java/com/slatto/global/config/EndpointAuthentication.java b/src/main/java/com/slatto/global/config/EndpointAuthentication.java new file mode 100644 index 00000000..68cc9f60 --- /dev/null +++ b/src/main/java/com/slatto/global/config/EndpointAuthentication.java @@ -0,0 +1,38 @@ +package com.slatto.global.config; + +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import org.springframework.web.method.HandlerMethod; + +/** + * 엔드포인트가 문서상 어떤 인증을 요구하는지 판정한다. + * + *

Swagger 커스터마이저 두 곳에서 같은 판정을 쓰기 때문에 여기 모아 둔다. + * 판정 기준이 갈라지면 자물쇠 표시와 401 문서화가 서로 어긋난다. + */ +final class EndpointAuthentication { + + private EndpointAuthentication() { + } + + /** + * 토큰 없이 호출하는 것이 정상인 경로. 비어 있는 {@code @SecurityRequirements} 로 표시한다. + */ + static boolean isAnonymous(HandlerMethod handlerMethod) { + return handlerMethod.hasMethodAnnotation(SecurityRequirements.class); + } + + /** + * 토큰이 선택인 경로. {@link OptionalAuthentication} 으로 표시한다. + */ + static boolean isOptional(HandlerMethod handlerMethod) { + return handlerMethod.hasMethodAnnotation(OptionalAuthentication.class); + } + + /** + * 토큰이 없으면 401 로 막히는 경로. 표시가 하나도 없으면 여기에 해당한다. + */ + static boolean isRequired(HandlerMethod handlerMethod) { + return !isAnonymous(handlerMethod) && !isOptional(handlerMethod); + } + +} diff --git a/src/main/java/com/slatto/global/config/OptionalAuthentication.java b/src/main/java/com/slatto/global/config/OptionalAuthentication.java new file mode 100644 index 00000000..307bcc11 --- /dev/null +++ b/src/main/java/com/slatto/global/config/OptionalAuthentication.java @@ -0,0 +1,25 @@ +package com.slatto.global.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 토큰이 있어도 되고 없어도 되는 엔드포인트임을 문서에 표시한다. + * + *

게스트 참여 경로는 인증 없이 열려 있지만, 토큰을 함께 보내면 로그인 사용자로 처리된다. + * 문서에서 이 둘은 구분되지 않는다. 자물쇠만 보면 토큰이 필수처럼 읽히고, + * 자물쇠를 떼면 로그인 사용자로 호출할 방법이 없는 것처럼 읽힌다. + * + *

이 표시가 붙으면 OpenAPI 의 security 에 빈 요구사항을 함께 넣어 둘 다 허용임을 나타내고, + * 401 은 문서화하지 않는다. 실제로 인증 실패로 막히지 않는 경로이기 때문이다. + * + *

동작에는 영향을 주지 않는다. 실제 접근 제어는 {@code SecurityConfig} 가 결정한다. + */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface OptionalAuthentication { +} diff --git a/src/main/java/com/slatto/global/config/SwaggerAuthenticationCustomizer.java b/src/main/java/com/slatto/global/config/SwaggerAuthenticationCustomizer.java new file mode 100644 index 00000000..1d2054f1 --- /dev/null +++ b/src/main/java/com/slatto/global/config/SwaggerAuthenticationCustomizer.java @@ -0,0 +1,58 @@ +package com.slatto.global.config; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; + +import java.util.List; + +/** + * 토큰이 선택인 엔드포인트를 문서에 그대로 드러낸다. + * + *

문서 전체에 인증 요구가 걸려 있어서 기본값은 "토큰 필수"다. + * 게스트 참여 경로는 토큰 없이도 되고 있어도 되는데, 이 상태를 표현할 자리가 없어 + * 필수처럼 읽히거나 아예 로그인 사용자를 받지 않는 것처럼 읽힌다. + * + *

OpenAPI 는 security 목록에 빈 요구사항을 함께 넣으면 둘 다 허용이라는 뜻이 된다. + * 그 표현을 쓰고, 토큰 유무에 따라 무엇이 달라지는지는 설명으로 덧붙인다. + */ +@Component +public class SwaggerAuthenticationCustomizer implements OperationCustomizer { + + private static final String BEARER_AUTH = "bearerAuth"; + private static final String OPTIONAL_AUTH_NOTE = + "인증은 선택입니다. 토큰을 보내면 로그인 사용자로, 보내지 않으면 게스트로 처리됩니다."; + + @Override + public Operation customize(Operation operation, HandlerMethod handlerMethod) { + if (!EndpointAuthentication.isOptional(handlerMethod)) { + return operation; + } + + // 빈 요구사항이 "인증 없이도 허용"을 뜻한다. 앞의 항목과 함께 두면 둘 다 받는다는 의미가 된다. + operation.setSecurity(List.of( + new SecurityRequirement().addList(BEARER_AUTH), + new SecurityRequirement() + )); + + operation.setDescription(appendNote(operation.getDescription())); + + return operation; + } + + // 엔드포인트가 이미 적어 둔 설명을 지우지 않는다. + private String appendNote(String description) { + if (description == null || description.isBlank()) { + return OPTIONAL_AUTH_NOTE; + } + + if (description.contains(OPTIONAL_AUTH_NOTE)) { + return description; + } + + return description + "\n\n" + OPTIONAL_AUTH_NOTE; + } + +} diff --git a/src/main/java/com/slatto/global/config/SwaggerConfig.java b/src/main/java/com/slatto/global/config/SwaggerConfig.java index abf2f1b6..53d7362e 100644 --- a/src/main/java/com/slatto/global/config/SwaggerConfig.java +++ b/src/main/java/com/slatto/global/config/SwaggerConfig.java @@ -1,29 +1,96 @@ package com.slatto.global.config; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slatto.global.exception.ValidationErrorResponse; +import com.slatto.global.response.ApiResponse; +import com.slatto.global.response.code.CommonErrorCode; +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.Map; + @Configuration public class SwaggerConfig { + public static final String ERROR_RESPONSE_SCHEMA = "ErrorResponse"; + public static final String ERROR_RESPONSE_SCHEMA_REF = "#/components/schemas/" + ERROR_RESPONSE_SCHEMA; + + private static final String VALIDATION_ERROR_SCHEMA = "ValidationErrorResponse"; + private static final String VALIDATION_ERROR_SCHEMA_REF = "#/components/schemas/" + VALIDATION_ERROR_SCHEMA; + private static final String RESULT_PROPERTY = "result"; private static final String BEARER_AUTH = "bearerAuth"; @Bean - public OpenAPI openAPI() { + public OpenAPI openAPI(ObjectMapper objectMapper) { + Components components = new Components() + .addSecuritySchemes(BEARER_AUTH, new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + + registerErrorSchemas(components, objectMapper); + return new OpenAPI() .info(new Info() .title("SLAT-TO Backend API") .description("SLAT-TO backend API documentation") .version("v1")) - .components(new Components().addSecuritySchemes(BEARER_AUTH, new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))) + .components(components) .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)); } + + // 실패 응답 스키마는 실제 응답 클래스에서 뽑아낸다. + // 손으로 필드를 적어두면 응답 클래스가 바뀌었을 때 문서만 조용히 낡는다. + private void registerErrorSchemas(Components components, ObjectMapper objectMapper) { + ModelConverters.getInstance() + .readAllAsResolvedSchema(new AnnotatedType(ValidationErrorResponse.class)) + .referencedSchemas + .forEach(components::addSchemas); + + Schema errorResponse = resolveSchema(ApiResponse.class) + .description("실패 응답. 성공과 동일한 래퍼를 사용한다."); + + applyFailureExamples(errorResponse, objectMapper); + + // 래퍼의 result 는 제네릭이라 object 로만 잡힌다. + // 실패에서 result 에 들어갈 수 있는 유일한 본문을 직접 가리켜서, 검증 실패 구조가 문서에 드러나게 한다. + // 참조되지 않는 스키마는 springdoc 이 문서에서 걷어내므로 이 연결이 곧 등록 조건이기도 하다. + errorResponse.getProperties().put(RESULT_PROPERTY, new Schema<>() + .$ref(VALIDATION_ERROR_SCHEMA_REF) + .nullable(true) + .description("검증 실패에서만 필드 오류 목록이 담기고, 그 외에는 null 이다.")); + + components.addSchemas(ERROR_RESPONSE_SCHEMA, errorResponse); + } + + // 래퍼에 붙은 예시는 성공 기준이라 실패 스키마에 그대로 쓰면 문서가 거짓말을 한다. + // 실제 실패 응답을 직렬화해서 덮어쓴다. + private void applyFailureExamples(Schema schema, ObjectMapper objectMapper) { + Map failure = objectMapper.convertValue( + ApiResponse.failure(CommonErrorCode.INTERNAL_SERVER_ERROR), + new TypeReference<>() { + } + ); + + schema.getProperties().forEach((name, property) -> property.setExample(failure.get(name))); + } + + private Schema resolveSchema(Class type) { + ResolvedSchema resolved = ModelConverters.getInstance() + .readAllAsResolvedSchema(new AnnotatedType(type)); + Schema named = resolved.referencedSchemas.get(type.getSimpleName()); + + return named != null ? named : resolved.schema; + } + } diff --git a/src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java b/src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java new file mode 100644 index 00000000..5f2069fd --- /dev/null +++ b/src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java @@ -0,0 +1,170 @@ +package com.slatto.global.config; + +import com.slatto.global.response.code.CommonErrorCode; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 공통 에러 응답을 모든 엔드포인트 문서에 붙인다. + * + *

에러는 {@code GlobalExceptionHandler} 가 전역에서 처리하기 때문에 컨트롤러에는 흔적이 남지 않는다. + * 그래서 문서를 생성하면 성공 응답만 노출되고, 실제로는 존재하는 실패 응답이 명세에서 통째로 사라진다. + * 엔드포인트마다 손으로 적는 대신 여기서 한 번에 주입한다. + * + *

실제로 발생할 수 있는 상태 코드만 붙인다. 문서에 있는 상태 코드가 실제로 나지 않으면 + * 명세와 구현이 어긋난 것과 같기 때문에, 조건 없이 전부 붙이지 않는다. + */ +@Component +public class SwaggerErrorResponseCustomizer implements OperationCustomizer { + + private static final String JSON = org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + private static final String MULTIPART = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; + private static final String PATH_PARAMETER = "path"; + + @Override + public Operation customize(Operation operation, HandlerMethod handlerMethod) { + ApiResponses responses = operation.getResponses(); + + if (responses == null) { + return operation; + } + + // 400 은 파싱할 본문이나 파라미터가 있어야 발생한다. 둘 다 없으면 날 수 없다. + if (hasParameters(operation) || operation.getRequestBody() != null) { + addIfAbsent(responses, CommonErrorCode.BAD_REQUEST, badRequestContent()); + } + + // 401 은 토큰이 필수인 엔드포인트에서만 발생한다. + // 게스트 참여 경로는 토큰이 없어도 통과하기 때문에 여기서 제외된다. + if (EndpointAuthentication.isRequired(handlerMethod)) { + addIfAbsent(responses, CommonErrorCode.UNAUTHORIZED, singleExampleContent(CommonErrorCode.UNAUTHORIZED)); + } + + // 404 는 경로로 리소스를 찾는 엔드포인트에서만 발생한다. + if (hasPathParameter(operation)) { + addIfAbsent(responses, CommonErrorCode.NOT_FOUND, singleExampleContent(CommonErrorCode.NOT_FOUND)); + } + + // 413 은 multipart 본문을 받는 엔드포인트에서만 발생한다. + // 한도를 넘긴 요청은 본문을 읽는 단계에서 끊겨 컨트롤러에 닿지도 않는다. + if (consumesMultipart(operation)) { + addIfAbsent( + responses, + CommonErrorCode.PAYLOAD_TOO_LARGE, + singleExampleContent(CommonErrorCode.PAYLOAD_TOO_LARGE) + ); + } + + // 500 은 처리되지 않은 예외를 잡는 핸들러가 있어 모든 엔드포인트에서 가능하다. + addIfAbsent( + responses, + CommonErrorCode.INTERNAL_SERVER_ERROR, + singleExampleContent(CommonErrorCode.INTERNAL_SERVER_ERROR) + ); + + return operation; + } + + // 엔드포인트가 직접 선언한 응답이 우선한다. 여기서 덮어쓰면 개별 문서화가 무의미해진다. + private void addIfAbsent(ApiResponses responses, CommonErrorCode errorCode, Content content) { + String status = String.valueOf(errorCode.getHttpStatus().value()); + + if (responses.containsKey(status)) { + return; + } + + responses.addApiResponse(status, new ApiResponse() + .description(errorCode.getMessage()) + .content(content)); + } + + private Content badRequestContent() { + MediaType mediaType = new MediaType().schema(errorSchemaRef()); + + mediaType.addExamples("검증 실패", new Example() + .summary("요청 필드가 검증 조건을 만족하지 못한 경우") + .value(validationFailureExample())); + + mediaType.addExamples("잘못된 요청", new Example() + .summary("본문 파싱 실패, 타입 불일치, 필수 파라미터 누락") + .value(errorExample(CommonErrorCode.BAD_REQUEST))); + + return new Content().addMediaType(JSON, mediaType); + } + + private Content singleExampleContent(CommonErrorCode errorCode) { + return new Content().addMediaType(JSON, new MediaType() + .schema(errorSchemaRef()) + .example(errorExample(errorCode))); + } + + private Schema errorSchemaRef() { + return new Schema<>().$ref(SwaggerConfig.ERROR_RESPONSE_SCHEMA_REF); + } + + // 예시를 손으로 적으면 코드나 메시지가 바뀔 때 문서만 조용히 낡는다. enum 에서 그대로 가져온다. + private Map errorExample(CommonErrorCode errorCode) { + Map example = new LinkedHashMap<>(); + example.put("isSuccess", false); + example.put("code", errorCode.getCode()); + example.put("message", errorCode.getMessage()); + example.put("result", null); + + return example; + } + + private Map validationFailureExample() { + Map fieldError = new LinkedHashMap<>(); + fieldError.put("field", "title"); + fieldError.put("reason", "공백일 수 없습니다"); + + Map result = new LinkedHashMap<>(); + result.put("errors", List.of(fieldError)); + + Map example = errorExample(CommonErrorCode.BAD_REQUEST); + example.put("result", result); + + return example; + } + + private boolean hasParameters(Operation operation) { + List parameters = operation.getParameters(); + + return parameters != null && !parameters.isEmpty(); + } + + private boolean consumesMultipart(Operation operation) { + RequestBody requestBody = operation.getRequestBody(); + + if (requestBody == null || requestBody.getContent() == null) { + return false; + } + + return requestBody.getContent().keySet().stream().anyMatch(mediaType -> mediaType.startsWith(MULTIPART)); + } + + private boolean hasPathParameter(Operation operation) { + List parameters = operation.getParameters(); + + if (parameters == null) { + return false; + } + + return parameters.stream().anyMatch(parameter -> PATH_PARAMETER.equals(parameter.getIn())); + } + +} diff --git a/src/main/java/com/slatto/global/exception/GlobalExceptionHandler.java b/src/main/java/com/slatto/global/exception/GlobalExceptionHandler.java index 0423ad4b..509f40c0 100644 --- a/src/main/java/com/slatto/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/slatto/global/exception/GlobalExceptionHandler.java @@ -13,6 +13,8 @@ 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; +import org.springframework.web.multipart.MultipartException; import org.springframework.web.servlet.resource.NoResourceFoundException; import lombok.extern.slf4j.Slf4j; @@ -76,6 +78,35 @@ public ResponseEntity> handleNoResourceException(NoResourceFou .body(ApiResponse.failure(errorCode)); } + // multipart 한도 초과는 요청 본문을 읽는 단계에서 터진다. 컨트롤러에 닿지 않으니 + // 서비스의 파일 크기 검증(PROJECT_FILE_SIZE400 등)은 실행조차 되지 않는다. + // 여기서 잡지 않으면 handleUnexpectedException 이 받아 COMMON500 을 내보내고, + // 프론트는 "파일이 너무 큽니다" 대신 서버 오류를 표시하게 된다. + // 앞단 nginx 도 한도를 넘기면 413 을 주므로 상태 코드를 413 으로 맞춰 프론트가 한 갈래로 처리하게 한다. + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handlePayloadTooLargeException( + MaxUploadSizeExceededException exception + ) { + CommonErrorCode errorCode = CommonErrorCode.PAYLOAD_TOO_LARGE; + log.warn("[Multipart] 업로드 한도를 초과한 요청입니다. maxUploadSize={}", exception.getMaxUploadSize()); + + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ApiResponse.failure(errorCode)); + } + + // 한도 초과 외의 multipart 해석 실패(본문이 잘림, boundary 불일치 등)는 클라이언트 요청 문제다. + // 서버 오류로 올리면 업로드 중 연결이 끊길 때마다 500 로그가 쌓인다. + @ExceptionHandler(MultipartException.class) + public ResponseEntity> handleMultipartException(MultipartException exception) { + CommonErrorCode errorCode = CommonErrorCode.BAD_REQUEST; + log.warn("[Multipart] 요청을 해석하지 못했습니다.", exception); + + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ApiResponse.failure(errorCode)); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpectedException(Exception exception) { log.error("[Unhandled Exception] ", exception); diff --git a/src/main/java/com/slatto/global/exception/ValidationErrorResponse.java b/src/main/java/com/slatto/global/exception/ValidationErrorResponse.java index ee5d8cd7..ad59dcdd 100644 --- a/src/main/java/com/slatto/global/exception/ValidationErrorResponse.java +++ b/src/main/java/com/slatto/global/exception/ValidationErrorResponse.java @@ -1,9 +1,12 @@ package com.slatto.global.exception; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import org.springframework.validation.FieldError; +@Schema(description = "요청 필드 검증에 실패했을 때 공통 응답의 result 에 담기는 본문") public record ValidationErrorResponse( + @Schema(description = "검증에 실패한 필드 목록") List errors ) { @@ -15,8 +18,12 @@ public static ValidationErrorResponse from(List fieldErrors) { return new ValidationErrorResponse(errors); } + @Schema(description = "필드 단위 검증 실패 내용") public record FieldErrorDetail( + @Schema(description = "검증에 실패한 필드명", example = "title") String field, + + @Schema(description = "실패 사유", example = "공백일 수 없습니다") String reason ) { diff --git a/src/main/java/com/slatto/global/health/controller/HealthCheckController.java b/src/main/java/com/slatto/global/health/controller/HealthCheckController.java index c0a61d66..e6b73ad7 100644 --- a/src/main/java/com/slatto/global/health/controller/HealthCheckController.java +++ b/src/main/java/com/slatto/global/health/controller/HealthCheckController.java @@ -4,6 +4,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -12,7 +13,11 @@ @RestController public class HealthCheckController { - @Operation(summary = "서버 상태 확인") + @Operation( + summary = "서버 상태 확인", + description = "서버가 요청을 받을 수 있는지만 확인한다. DB 등 외부 의존성 상태는 확인하지 않는다." + ) + @SecurityRequirements @GetMapping("/api/v1/health") public ApiResponse checkHealth() { return ApiResponse.success(CommonSuccessCode.OK, new HealthCheckResponse("OK")); diff --git a/src/main/java/com/slatto/global/response/ApiResponse.java b/src/main/java/com/slatto/global/response/ApiResponse.java index 1eaef5ec..b09d812a 100644 --- a/src/main/java/com/slatto/global/response/ApiResponse.java +++ b/src/main/java/com/slatto/global/response/ApiResponse.java @@ -3,19 +3,25 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.slatto.global.response.code.BaseCode; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; @Getter @JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +@Schema(description = "모든 API 가 사용하는 공통 응답 래퍼. 성공과 실패가 같은 구조를 사용한다.") public class ApiResponse { + @Schema(description = "요청 성공 여부", example = "true") @JsonProperty("isSuccess") private final boolean success; + @Schema(description = "응답 코드. 도메인별 접두사와 HTTP 상태를 조합한다.", example = "COMMON200") private final String code; + @Schema(description = "응답 메시지", example = "요청에 성공했습니다.") private final String message; + @Schema(description = "응답 데이터. 실패 시 null 이며, 검증 실패에서만 필드 오류 목록이 담긴다.") private final T result; private ApiResponse(BaseCode baseCode, T result) { diff --git a/src/main/java/com/slatto/global/response/code/CommonErrorCode.java b/src/main/java/com/slatto/global/response/code/CommonErrorCode.java index 76673c1f..dbf7911a 100644 --- a/src/main/java/com/slatto/global/response/code/CommonErrorCode.java +++ b/src/main/java/com/slatto/global/response/code/CommonErrorCode.java @@ -14,6 +14,7 @@ public enum CommonErrorCode implements BaseCode { NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "요청한 리소스를 찾을 수 없습니다."), METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON405", "지원하지 않는 HTTP 메서드입니다."), CONFLICT(HttpStatus.CONFLICT, "COMMON409", "요청이 현재 상태와 충돌합니다."), + PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "COMMON413", "업로드 용량이 허용된 한도를 초과했습니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 내부 오류가 발생했습니다."); private final HttpStatus httpStatus; diff --git a/src/main/resources/db/migration/V016__portfolio_period.sql b/src/main/resources/db/migration/V018__portfolio_period.sql similarity index 100% rename from src/main/resources/db/migration/V016__portfolio_period.sql rename to src/main/resources/db/migration/V018__portfolio_period.sql diff --git a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java new file mode 100644 index 00000000..35e04344 --- /dev/null +++ b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java @@ -0,0 +1,326 @@ +package com.slatto.global.config; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slatto.global.response.ApiResponse; +import com.slatto.global.response.code.CommonErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * 실제로 생성되는 OpenAPI 문서를 검증한다. + * + *

문서는 애노테이션에서 조립되기 때문에 애노테이션을 빠뜨려도 빌드가 깨지지 않는다. + * 그래서 누락은 배포 후에야 드러난다. 생성 결과를 직접 확인해서 먼저 깨지게 한다. + */ +@SpringBootTest +@AutoConfigureMockMvc +class OpenApiDocumentationTest { + + private static final Set HTTP_METHODS = + Set.of("get", "post", "put", "patch", "delete", "options", "head", "trace"); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private JsonNode apiDocs; + + @BeforeEach + void fetchApiDocs() throws Exception { + String body = mockMvc.perform(get("/v3/api-docs")) + .andReturn() + .getResponse() + .getContentAsString(); + + apiDocs = objectMapper.readTree(body); + } + + @Test + @DisplayName("문서에 노출된 모든 엔드포인트는 설명을 가진다") + void everyExposedOperationHasSummary() { + List missing = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + JsonNode summary = operation.get("summary"); + + if (summary == null || summary.asText().isBlank()) { + missing.add(httpMethod.toUpperCase() + " " + path); + } + }); + + assertThat(missing) + .as("@Operation 이 없는 엔드포인트. 문서에서 감추려면 @Hidden 을 붙인다.") + .isEmpty(); + } + + // summary 만 있으면 이름만 아는 상태다. 호출하는 쪽이 알아야 할 제약은 코드를 열어봐야 나온다. + // summary 를 그대로 옮겨 적은 설명은 그 공백을 메우지 않으므로 없는 것으로 친다. + @Test + @DisplayName("문서에 노출된 모든 엔드포인트는 summary 를 되풀이하지 않는 설명을 가진다") + void everyExposedOperationHasMeaningfulDescription() { + List missing = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + String description = operation.path("description").asText("").strip(); + String summary = operation.path("summary").asText("").strip(); + + if (description.isBlank() || description.equals(summary)) { + missing.add(httpMethod.toUpperCase() + " " + path); + } + }); + + assertThat(missing) + .as("@Operation(description = ...) 이 없는 엔드포인트") + .isEmpty(); + } + + @Test + @DisplayName("모든 엔드포인트는 500 응답을 문서화한다") + void everyOperationDocumentsServerError() { + List missing = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + if (!operation.path("responses").has("500")) { + missing.add(httpMethod.toUpperCase() + " " + path); + } + }); + + assertThat(missing).isEmpty(); + } + + @Test + @DisplayName("경로 변수를 받는 엔드포인트는 404 응답을 문서화한다") + void pathVariableOperationsDocumentNotFound() { + List missing = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + if (hasPathParameter(operation) && !operation.path("responses").has("404")) { + missing.add(httpMethod.toUpperCase() + " " + path); + } + }); + + assertThat(missing).isEmpty(); + } + + // 413 은 업로드 한도를 서블릿 컨테이너가 강제하는 multipart 경로에서만 난다. + // 다른 엔드포인트에 붙으면 실제로 나지 않는 상태 코드가 문서에 실린다. + @Test + @DisplayName("413 은 multipart 본문을 받는 엔드포인트에만 문서화된다") + void payloadTooLargeIsDocumentedOnlyOnMultipartOperations() { + List wrong = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + boolean documentsPayloadTooLarge = operation.path("responses").has("413"); + + if (consumesMultipart(operation) != documentsPayloadTooLarge) { + wrong.add(httpMethod.toUpperCase() + " " + path); + } + }); + + assertThat(wrong).isEmpty(); + } + + // 401 을 일괄로 붙이면 인증 없이 열린 경로에도 발생하지 않는 상태 코드가 실린다. + // 자물쇠 표시와 401 문서화는 항상 같은 판정에서 나와야 한다. + @Test + @DisplayName("401 은 인증이 필수인 엔드포인트에만 문서화된다") + void unauthorizedIsDocumentedOnlyWhereAuthenticationIsRequired() { + List wrong = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + boolean documentsUnauthorized = operation.path("responses").has("401"); + + if (requiresAuthentication(operation) != documentsUnauthorized) { + wrong.add(httpMethod.toUpperCase() + " " + path); + } + }); + + assertThat(wrong) + .as("인증이 필수면 401 이 있어야 하고, 공개거나 선택이면 없어야 한다.") + .isEmpty(); + } + + @Test + @DisplayName("인증이 선택인 엔드포인트는 토큰 없는 호출도 허용한다고 표시한다") + void optionalAuthenticationOperationsAllowAnonymousCalls() { + List optional = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + if (allowsAnonymous(operation) && !operation.path("security").isEmpty()) { + optional.add(httpMethod.toUpperCase() + " " + path); + + assertThat(operation.path("description").asText()) + .as(httpMethod.toUpperCase() + " " + path + " 는 인증이 선택이라는 설명을 가진다") + .contains("인증은 선택입니다"); + } + }); + + assertThat(optional).isNotEmpty(); + } + + @Test + @DisplayName("실패 응답 스키마는 실제 응답 래퍼와 같은 필드를 가진다") + void errorResponseSchemaMatchesActualWrapper() { + Set documented = fieldNames(errorResponseSchema().path("properties")); + + assertThat(documented).isEqualTo(actualResponseKeys()); + } + + private JsonNode errorResponseSchema() { + return apiDocs.path("components").path("schemas").path("ErrorResponse"); + } + + @Test + @DisplayName("실패 응답 스키마의 예시는 실제 실패 응답 값과 일치한다") + void errorResponseSchemaExamplesMatchActualResponse() { + JsonNode properties = errorResponseSchema().path("properties"); + Map actual = actualResponse(CommonErrorCode.INTERNAL_SERVER_ERROR); + Map documented = new LinkedHashMap<>(); + + properties.fieldNames().forEachRemaining(name -> { + JsonNode example = properties.path(name).path("example"); + + if (!example.isMissingNode()) { + documented.put(name, objectMapper.convertValue(example, Object.class)); + } + }); + + assertThat(documented).isNotEmpty(); + assertThat(documented).allSatisfy((name, value) -> assertThat(value).isEqualTo(actual.get(name))); + } + + // 참조되지 않는 스키마는 springdoc 이 문서에서 걷어낸다. + // 연결이 끊기면 검증 실패 응답의 구조가 통째로 사라지므로 참조 자체를 고정한다. + @Test + @DisplayName("검증 실패 본문 스키마가 실패 응답에서 참조된다") + void validationErrorSchemaStaysReachable() { + JsonNode schemas = apiDocs.path("components").path("schemas"); + + assertThat(errorResponseSchema().path("properties").path("result").path("$ref").asText()) + .isEqualTo("#/components/schemas/ValidationErrorResponse"); + assertThat(schemas.has("ValidationErrorResponse")).isTrue(); + assertThat(schemas.path("ValidationErrorResponse").path("properties").path("errors") + .path("items").path("$ref").asText()) + .isEqualTo("#/components/schemas/FieldErrorDetail"); + assertThat(schemas.has("FieldErrorDetail")).isTrue(); + } + + @Test + @DisplayName("문서의 500 예시는 실제 응답 객체와 일치한다") + void serverErrorExampleMatchesActualResponse() { + Map actual = actualResponse(CommonErrorCode.INTERNAL_SERVER_ERROR); + List> examples = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + JsonNode example = operation.path("responses").path("500") + .path("content").path("application/json").path("example"); + + if (!example.isMissingNode()) { + examples.add(objectMapper.convertValue(example, new TypeReference<>() { + })); + } + }); + + assertThat(examples).isNotEmpty(); + assertThat(examples).allSatisfy(example -> assertThat(example).isEqualTo(actual)); + } + + // 실제 응답을 직렬화해서 비교한다. 필드명을 테스트에 적어두면 그 하드코딩도 같이 낡는다. + private Map actualResponse(CommonErrorCode errorCode) { + return objectMapper.convertValue(ApiResponse.failure(errorCode), new TypeReference<>() { + }); + } + + private Set actualResponseKeys() { + return new LinkedHashSet<>(actualResponse(CommonErrorCode.INTERNAL_SERVER_ERROR).keySet()); + } + + // OpenAPI 는 security 목록에 빈 요구사항이 있으면 인증 없이도 호출할 수 있다는 뜻이다. + // 목록 자체가 비어 있으면 전역 인증 요구를 통째로 벗겨낸 공개 엔드포인트다. + private boolean allowsAnonymous(JsonNode operation) { + JsonNode security = operation.path("security"); + + if (security.isMissingNode()) { + return false; + } + + if (security.isEmpty()) { + return true; + } + + for (JsonNode requirement : security) { + if (requirement.isEmpty()) { + return true; + } + } + + return false; + } + + private boolean requiresAuthentication(JsonNode operation) { + return !allowsAnonymous(operation); + } + + private boolean consumesMultipart(JsonNode operation) { + return fieldNames(operation.path("requestBody").path("content")).stream() + .anyMatch(mediaType -> mediaType.startsWith("multipart/form-data")); + } + + private boolean hasPathParameter(JsonNode operation) { + for (JsonNode parameter : operation.path("parameters")) { + if ("path".equals(parameter.path("in").asText())) { + return true; + } + } + + return false; + } + + private Set fieldNames(JsonNode node) { + Set names = new LinkedHashSet<>(); + node.fieldNames().forEachRemaining(names::add); + + return names; + } + + private void forEachOperation(OperationVisitor visitor) { + JsonNode paths = apiDocs.path("paths"); + + paths.fieldNames().forEachRemaining(path -> { + JsonNode pathItem = paths.path(path); + + pathItem.fieldNames().forEachRemaining(httpMethod -> { + if (HTTP_METHODS.contains(httpMethod)) { + visitor.visit(path, httpMethod, pathItem.path(httpMethod)); + } + }); + }); + } + + @FunctionalInterface + private interface OperationVisitor { + + void visit(String path, String httpMethod, JsonNode operation); + + } + +} diff --git a/src/test/java/com/slatto/global/exception/MultipartUploadLimitTest.java b/src/test/java/com/slatto/global/exception/MultipartUploadLimitTest.java new file mode 100644 index 00000000..f51891fd --- /dev/null +++ b/src/test/java/com/slatto/global/exception/MultipartUploadLimitTest.java @@ -0,0 +1,81 @@ +package com.slatto.global.exception; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * multipart 한도를 넘긴 업로드가 공통 응답 포맷으로 나가는지 검증한다. + * + *

한도 초과는 요청 본문을 읽는 단계에서 터져 컨트롤러에 닿지 않는다. + * 그래서 서비스의 파일 크기 검증은 실행되지 않고, 전역 핸들러가 잡지 않으면 COMMON500 이 나간다. + * + *

MockMvc 는 multipart 요청을 테스트가 직접 조립하기 때문에 파싱 자체가 일어나지 않는다. + * 한도는 서블릿 컨테이너가 강제하므로 실제 포트를 띄워 진짜 HTTP 요청을 보낸다. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.servlet.multipart.max-file-size=1KB", + "spring.servlet.multipart.max-request-size=10MB" + } +) +class MultipartUploadLimitTest { + + // 인증 필터에서 먼저 걸리지 않도록 permitAll 경로를 쓴다. + // multipart 파싱은 핸들러를 찾기 전에 일어나므로 이 경로가 파일을 받는지는 상관없다. + private static final String PERMIT_ALL_PATH = "/api/v1/videos/1/feedbacks"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private TestRestTemplate restTemplate; + + @Test + @DisplayName("업로드 한도를 넘긴 multipart 요청은 500 이 아니라 413 COMMON413 으로 응답한다") + void respondsWithPayloadTooLargeWhenUploadLimitExceeded() throws Exception { + ResponseEntity response = upload(oversizedFile()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE); + + JsonNode body = objectMapper.readTree(response.getBody()); + + assertThat(body.get("isSuccess").asBoolean()).isFalse(); + assertThat(body.get("code").asText()).isEqualTo("COMMON413"); + assertThat(body.get("message").asText()).isNotBlank(); + } + + private ResponseEntity upload(ByteArrayResource file) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", file); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + return restTemplate.postForEntity(PERMIT_ALL_PATH, new HttpEntity<>(body, headers), String.class); + } + + private ByteArrayResource oversizedFile() { + return new ByteArrayResource(new byte[4096]) { + @Override + public String getFilename() { + return "oversized.bin"; + } + }; + } + +}