diff --git a/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java b/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java new file mode 100644 index 0000000..99dcebf --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java @@ -0,0 +1,28 @@ +package com.dduru.gildongmu.common.annotation; + +import com.dduru.gildongmu.common.exception.ErrorCode; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * API 엔드포인트에 자동으로 에러 응답을 추가하기 위한 어노테이션 + * ErrorCode enum을 지정하면 해당 ErrorCode의 HTTP 상태 코드가 자동으로 사용됩니다. + * + * 사용 예시: + *
+ * {@code @ApiErrorResponses({ErrorCode.NICKNAME_ALREADY_TAKEN, ErrorCode.PROFILE_NOT_FOUND})}
+ * ResponseEntity> method();
+ * 
+ */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorResponses { + /** + * 추가할 ErrorCode enum 목록 (예: ErrorCode.NICKNAME_ALREADY_TAKEN, ErrorCode.PROFILE_NOT_FOUND) + * 각 ErrorCode의 HTTP 상태 코드가 자동으로 사용됩니다. + */ + ErrorCode[] value(); +} diff --git a/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java b/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java deleted file mode 100644 index df4e592..0000000 --- a/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.dduru.gildongmu.common.annotation; - -import com.dduru.gildongmu.common.exception.ErrorResponse; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@ApiResponses({ - @ApiResponse( - responseCode = "400", - description = "잘못된 요청", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "InvalidInput", - value = """ - { - "status": 400, - "data": { - "errorCode": "INVALID_INPUT_VALUE", - "field": null, - "message": "잘못된 입력 값입니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "401", - description = "인증 실패", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "Unauthorized", - value = """ - { - "status": 401, - "data": { - "errorCode": "UNAUTHORIZED", - "field": null, - "message": "인증되지 않은 사용자입니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "404", - description = "리소스를 찾을 수 없음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "NotFound", - value = """ - { - "status": 404, - "data": { - "errorCode": "NOT_FOUND", - "field": null, - "message": "요청한 리소스를 찾을 수 없습니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "500", - description = "서버 오류", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "InternalServerError", - value = """ - { - "status": 500, - "data": { - "errorCode": "INTERNAL_SERVER_ERROR", - "field": null, - "message": "서버 오류가 발생했습니다." - } - } - """ - ) - ) - ) -}) -public @interface CommonApiResponses { -} diff --git a/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java b/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java new file mode 100644 index 0000000..5e7e8bf --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java @@ -0,0 +1,175 @@ +package com.dduru.gildongmu.common.exception; + +import com.dduru.gildongmu.common.annotation.ApiErrorResponses; +import io.swagger.v3.oas.models.Components; +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.responses.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * OpenAPI 에러 응답 자동 추가 커스터마이저 + * + * @ApiErrorResponses 어노테이션에 ErrorCode enum을 지정하면, + * 해당 ErrorCode의 HTTP 상태 코드와 메시지가 자동으로 사용됩니다. + */ +@Slf4j +@Configuration +public class ApiErrorResponseDocsCustomizer { + + /** + * ErrorResponse 스키마를 Components에 등록 + */ + @Bean + public OpenApiCustomizer errorResponseSchemaCustomizer() { + return openApi -> { + Components components = openApi.getComponents(); + if (components == null) { + components = new Components(); + openApi.setComponents(components); + } + + // ErrorResponse 스키마 정의 + Schema errorResponseSchema = new Schema<>() + .type("object") + .addProperty("status", new Schema<>().type("integer").format("int32").example(400)) + .addProperty("data", new Schema<>() + .type("object") + .addProperty("errorCode", new Schema<>().type("string").example("INVALID_INPUT_VALUE")) + .addProperty("field", new Schema<>().type("string").nullable(true)) + .addProperty("message", new Schema<>().type("string").example("잘못된 입력 값입니다."))); + + components.addSchemas("ErrorResponse", errorResponseSchema); + }; + } + + /** + * @ApiErrorResponses 어노테이션에 지정된 ErrorCode enum을 기반으로 에러 응답 자동 추가 + */ + @Bean + public OperationCustomizer operationCustomizer() { + return (operation, handlerMethod) -> { + ErrorCode[] errorCodes = new ErrorCode[0]; + + Method method = handlerMethod.getMethod(); + Class declaringClass = method.getDeclaringClass(); + String methodName = method.getName(); + + // 모든 인터페이스에서 메서드를 찾아 어노테이션 확인 + for (Class iface : declaringClass.getInterfaces()) { + // 인터페이스의 모든 메서드 검색 (파라미터 타입이 다를 수 있으므로) + for (Method interfaceMethod : iface.getMethods()) { + // 메서드 이름이 같고 어노테이션이 있으면 사용 + if (interfaceMethod.getName().equals(methodName) + && interfaceMethod.isAnnotationPresent(ApiErrorResponses.class)) { + errorCodes = interfaceMethod.getAnnotation(ApiErrorResponses.class).value(); + break; + } + } + if (errorCodes.length > 0) { + break; + } + + // 인터페이스 레벨 어노테이션 확인 + if (iface.isAnnotationPresent(ApiErrorResponses.class)) { + errorCodes = iface.getAnnotation(ApiErrorResponses.class).value(); + break; + } + } + + // 클래스 레벨 어노테이션 확인 + if (errorCodes.length == 0 && declaringClass.isAnnotationPresent(ApiErrorResponses.class)) { + errorCodes = declaringClass.getAnnotation(ApiErrorResponses.class).value(); + } + + // 메서드 레벨 어노테이션 확인 (메서드 레벨이 우선) + if (method.isAnnotationPresent(ApiErrorResponses.class)) { + errorCodes = method.getAnnotation(ApiErrorResponses.class).value(); + } + + if (errorCodes.length > 0) { + addErrorResponses(operation, errorCodes); + } + + return operation; + }; + } + + /** + * ErrorCode enum 배열을 기반으로 에러 응답 추가 + * 각 ErrorCode에 대해 해당하는 HTTP 상태 코드의 응답을 추가하고, 예시를 포함합니다. + * 같은 HTTP 상태 코드를 가진 여러 ErrorCode가 있으면 모두 examples에 추가합니다. + */ + private void addErrorResponses(io.swagger.v3.oas.models.Operation operation, ErrorCode[] errorCodes) { + // ErrorResponse 스키마 참조 + Schema errorResponseSchema = new Schema<>() + .$ref("#/components/schemas/ErrorResponse"); + + // HTTP 상태 코드별로 ErrorCode들을 그룹화 + Map> statusCodeToErrorCodes = new HashMap<>(); + + for (ErrorCode errorCode : errorCodes) { + String statusCode = String.valueOf(errorCode.getStatus()); + statusCodeToErrorCodes.computeIfAbsent(statusCode, k -> new java.util.ArrayList<>()).add(errorCode); + } + + // 각 상태 코드에 대해 에러 응답 추가 + for (Map.Entry> entry : statusCodeToErrorCodes.entrySet()) { + String statusCode = entry.getKey(); + java.util.List errorCodeList = entry.getValue(); + + // 이미 해당 상태 코드의 응답이 있으면 스킵 + if (operation.getResponses().containsKey(statusCode)) { + continue; + } + + // 첫 번째 ErrorCode를 기본으로 사용 (description) + ErrorCode primaryErrorCode = errorCodeList.get(0); + + // 각 ErrorCode에 대한 예시 생성 + Map examples = new HashMap<>(); + for (ErrorCode errorCode : errorCodeList) { + Map errorResponseMap = new LinkedHashMap<>(); + errorResponseMap.put("status", errorCode.getStatus()); + + Map dataMap = new LinkedHashMap<>(); + dataMap.put("errorCode", errorCode.name()); + dataMap.put("field", null); + dataMap.put("message", errorCode.getMessage()); + errorResponseMap.put("data", dataMap); + + Example example = new Example(); + example.setValue(errorResponseMap); + example.setSummary(errorCode.getMessage()); + examples.put(errorCode.name(), example); + } + + // MediaType 생성 (여러 예시가 있으면 examples 사용, 하나만 있으면 example 사용) + MediaType mediaType = new MediaType().schema(errorResponseSchema); + if (examples.size() > 1) { + mediaType.setExamples(examples); + } else if (examples.size() == 1) { + Example singleExample = examples.values().iterator().next(); + mediaType.setExample(singleExample.getValue()); + } + + // 에러 응답 생성 + ApiResponse apiResponse = new ApiResponse() + .description(primaryErrorCode.getMessage()) + .content(new Content().addMediaType("application/json", mediaType)); + + operation.getResponses().addApiResponse(statusCode, apiResponse); + } + } +} diff --git a/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java index cef11f5..95efa4c 100644 --- a/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java +++ b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java @@ -1,7 +1,8 @@ package com.dduru.gildongmu.profile.controller; +import com.dduru.gildongmu.common.annotation.ApiErrorResponses; import com.dduru.gildongmu.common.dto.ApiResult; -import com.dduru.gildongmu.common.exception.ErrorResponse; +import com.dduru.gildongmu.common.exception.ErrorCode; import com.dduru.gildongmu.profile.dto.NicknameRandomResponse; import com.dduru.gildongmu.profile.dto.NicknameUpdateRequest; import com.dduru.gildongmu.profile.dto.NicknameValidateResponse; @@ -10,7 +11,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -28,94 +28,18 @@ public interface ProfileApiDocs { description = "닉네임 수정 성공", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResult.class)) - ), - @ApiResponse( - responseCode = "400", - description = "잘못된 요청 - 닉네임 형식이 올바르지 않거나 검증 규칙을 위반함", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "InvalidNickname", - value = """ - { - "status": 400, - "data": { - "errorCode": "NICKNAME_INVALID_LENGTH", - "field": "nickname", - "message": "닉네임은 2자 이상 14자 이하로 입력해주세요." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "401", - description = "인증 필요 - 유효한 인증 토큰이 필요함", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "Unauthorized", - value = """ - { - "status": 401, - "data": { - "errorCode": "UNAUTHORIZED", - "field": null, - "message": "인증되지 않은 사용자입니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "404", - description = "프로필을 찾을 수 없음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "ProfileNotFound", - value = """ - { - "status": 404, - "data": { - "errorCode": "PROFILE_NOT_FOUND", - "field": null, - "message": "해당 유저의 프로필을 찾을 수 없습니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "409", - description = "닉네임 중복 - 이미 사용 중인 닉네임임", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "NicknameAlreadyTaken", - value = """ - { - "status": 409, - "data": { - "errorCode": "NICKNAME_ALREADY_TAKEN", - "field": null, - "message": "이미 사용 중인 닉네임입니다." - } - } - """ - ) - ) ) }) + @ApiErrorResponses({ + ErrorCode.NICKNAME_INVALID_LENGTH, + ErrorCode.NICKNAME_INVALID_CHARACTERS, + ErrorCode.NICKNAME_CONTAINS_BAD_WORD, + ErrorCode.UNAUTHORIZED, + ErrorCode.PROFILE_NOT_FOUND, + ErrorCode.NICKNAME_ALREADY_TAKEN + }) ResponseEntity> updateNickname( - @Parameter(hidden = true) Long id, + @Parameter(hidden = true) Long id, @Valid NicknameUpdateRequest request ); @@ -126,50 +50,14 @@ ResponseEntity> updateNickname( description = "닉네임 유효성 확인 성공 - 사용 가능한 닉네임임", content = @Content(mediaType = "application/json", schema = @Schema(implementation = NicknameValidateResponse.class)) - ), - @ApiResponse( - responseCode = "400", - description = "잘못된 요청 - 닉네임 형식이 올바르지 않거나 검증 규칙을 위반함 (예: 길이, 특수문자, 금지 단어 등)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "InvalidNickname", - value = """ - { - "status": 400, - "data": { - "errorCode": "NICKNAME_INVALID_LENGTH", - "field": "nickname", - "message": "닉네임은 2자 이상 14자 이하로 입력해주세요." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "409", - description = "닉네임 중복 - 이미 사용 중인 닉네임임", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "NicknameAlreadyTaken", - value = """ - { - "status": 409, - "data": { - "errorCode": "NICKNAME_ALREADY_TAKEN", - "field": null, - "message": "이미 사용 중인 닉네임입니다." - } - } - """ - ) - ) ) }) + @ApiErrorResponses({ + ErrorCode.NICKNAME_INVALID_LENGTH, + ErrorCode.NICKNAME_INVALID_CHARACTERS, + ErrorCode.NICKNAME_CONTAINS_BAD_WORD, + ErrorCode.NICKNAME_ALREADY_TAKEN + }) ResponseEntity> checkNickname( @Parameter( description = "유효성 체크할 닉네임", @@ -185,29 +73,9 @@ ResponseEntity> checkNickname( description = "랜덤 닉네임 생성 성공", content = @Content(mediaType = "application/json", schema = @Schema(implementation = NicknameRandomResponse.class)) - ), - @ApiResponse( - responseCode = "401", - description = "인증 필요 - 유효한 인증 토큰이 필요함", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "Unauthorized", - value = """ - { - "status": 401, - "data": { - "errorCode": "UNAUTHORIZED", - "field": null, - "message": "인증되지 않은 사용자입니다." - } - } - """ - ) - ) ) }) + @ApiErrorResponses({ErrorCode.UNAUTHORIZED}) ResponseEntity> generateRandomNickname(); @Operation( @@ -220,122 +88,18 @@ ResponseEntity> checkNickname( description = "프로필 초기 설정 성공", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResult.class)) - ), - @ApiResponse( - responseCode = "400", - description = "잘못된 요청 - 입력 값 형식이 올바르지 않음 (예: 닉네임 형식 오류, 생년월일 형식 오류, 성별 형식 오류 등)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "InvalidInput", - value = """ - { - "status": 400, - "data": { - "errorCode": "INVALID_INPUT_VALUE", - "field": "birthday", - "message": "생년월일 형식이 올바르지 않습니다. (yyyy-MM-dd 형식)" - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "401", - description = "인증 필요 또는 인증 토큰 오류 - 유효한 인증 토큰이 필요하거나 인증 토큰이 유효하지 않음/만료됨", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = { - @ExampleObject( - name = "Unauthorized", - value = """ - { - "status": 401, - "data": { - "errorCode": "UNAUTHORIZED", - "field": null, - "message": "인증되지 않은 사용자입니다." - } - } - """ - ), - @ExampleObject( - name = "InvalidToken", - value = """ - { - "status": 401, - "data": { - "errorCode": "INVALID_TOKEN", - "field": null, - "message": "유효하지 않거나 만료된 인증 토큰입니다." - } - } - """ - ) - } - ) - ), - @ApiResponse( - responseCode = "404", - description = "프로필을 찾을 수 없음 - 해당 사용자의 프로필이 존재하지 않음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "ProfileNotFound", - value = """ - { - "status": 404, - "data": { - "errorCode": "PROFILE_NOT_FOUND", - "field": null, - "message": "해당 유저의 프로필을 찾을 수 없습니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "409", - description = "충돌 발생 - 닉네임 중복(NICKNAME_ALREADY_TAKEN) 또는 전화번호 중복(DUPLICATE_PHONE_NUMBER)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = { - @ExampleObject( - name = "NicknameAlreadyTaken", - value = """ - { - "status": 409, - "data": { - "errorCode": "NICKNAME_ALREADY_TAKEN", - "field": null, - "message": "이미 사용 중인 닉네임입니다." - } - } - """ - ), - @ExampleObject( - name = "DuplicatePhoneNumber", - value = """ - { - "status": 409, - "data": { - "errorCode": "DUPLICATE_PHONE_NUMBER", - "field": null, - "message": "이미 가입된 전화번호입니다." - } - } - """ - ) - } - ) ) }) + @ApiErrorResponses({ + ErrorCode.INVALID_INPUT_VALUE, + ErrorCode.NICKNAME_INVALID_LENGTH, + ErrorCode.NICKNAME_INVALID_CHARACTERS, + ErrorCode.UNAUTHORIZED, + ErrorCode.INVALID_TOKEN, + ErrorCode.PROFILE_NOT_FOUND, + ErrorCode.NICKNAME_ALREADY_TAKEN, + ErrorCode.DUPLICATE_PHONE_NUMBER + }) ResponseEntity> setupInitialProfile( @Parameter(hidden = true) Long userId, @Parameter( diff --git a/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java b/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java index b672354..690d347 100644 --- a/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java +++ b/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java @@ -1,7 +1,5 @@ package com.dduru.gildongmu.verification.controller; -import com.dduru.gildongmu.common.annotation.CommonApiResponses; -import com.dduru.gildongmu.common.annotation.CurrentUser; import com.dduru.gildongmu.common.dto.ApiResult; import com.dduru.gildongmu.common.exception.ErrorResponse; import com.dduru.gildongmu.verification.dto.VerificationSendRequest; @@ -18,13 +16,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "Phone Verification", description = "휴대폰 인증 API") public interface PhoneVerificationApiDocs { - @Operation(summary = "인증번호 발송", description = "소셜 로그인한 사용자만 전화번호로 인증번호를 발송할 수 있습니다.") - @CommonApiResponses + @Operation(summary = "인증번호 발송", description = "전화번호로 인증번호를 발송합니다.") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -93,12 +89,11 @@ public interface PhoneVerificationApiDocs { ) }) ResponseEntity> sendVerificationCode( - @Parameter(hidden = true) @CurrentUser Long userId, - @Valid @RequestBody VerificationSendRequest request + @Parameter(hidden = true) Long userId, + @Valid VerificationSendRequest request ); - @Operation(summary = "인증번호 검증", description = "소셜 로그인한 사용자만 발송된 인증번호를 검증하고 인증 토큰을 발급할 수 있습니다.") - @CommonApiResponses + @Operation(summary = "인증번호 검증", description = "발송된 인증번호를 검증하고 인증 토큰을 발급합니다.") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -188,7 +183,7 @@ ResponseEntity> sendVerificationCode( ) }) ResponseEntity> verifyCode( - @Parameter(hidden = true) @CurrentUser Long userId, - @Valid @RequestBody VerificationVerifyRequest request + @Parameter(hidden = true) Long userId, + @Valid VerificationVerifyRequest request ); }