diff --git a/config/naver-checkstyle-rules.xml b/config/naver-checkstyle-rules.xml index 06d3541..45acf10 100644 --- a/config/naver-checkstyle-rules.xml +++ b/config/naver-checkstyle-rules.xml @@ -32,9 +32,9 @@ The following rules in the Naver coding convention cannot be checked by this con - - - + + + @@ -55,13 +55,13 @@ The following rules in the Naver coding convention cannot be checked by this con - - - - - - + + + + + + + @@ -85,25 +85,25 @@ The following rules in the Naver coding convention cannot be checked by this con - - - - - - - - + + + + + + + + + + - - - - - - + + + + + + + diff --git a/src/main/java/capstone/relation/api/auth/controller/AuthController.java b/src/main/java/capstone/relation/api/auth/controller/AuthController.java index ce026af..1b75d53 100644 --- a/src/main/java/capstone/relation/api/auth/controller/AuthController.java +++ b/src/main/java/capstone/relation/api/auth/controller/AuthController.java @@ -12,9 +12,12 @@ import org.springframework.web.bind.annotation.RestController; import capstone.relation.api.auth.AuthProvider; +import capstone.relation.api.auth.docs.LoginAuthExceptionDocs; +import capstone.relation.api.auth.docs.RefreshAuthExceptionDocs; import capstone.relation.api.auth.jwt.response.RefreshTokenResponse; import capstone.relation.api.auth.jwt.response.TokenResponse; import capstone.relation.api.auth.service.AuthService; +import capstone.relation.global.annotation.ApiErrorExceptionsExample; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -47,21 +50,13 @@ public void getKakaoCode(@RequestParam String code, HttpServletResponse response @PostMapping(value = "/kakao", produces = MediaType.APPLICATION_JSON_VALUE) @Operation(summary = "카카오 로그인 인증", description = "초대받은 경우만 있는 토큰으로 카카오를 통한 로그인 인증을 처리합니다. " + "초대 토큰이 없는 경우에는 `inviteToken` 없이 요청합니다.") - @ApiResponse(responseCode = "200", description = "Successful operation", - content = @Content(schema = @Schema(implementation = TokenResponse.class))) + @ApiErrorExceptionsExample(LoginAuthExceptionDocs.class) public ResponseEntity loginWithKakaoCode( @Parameter(description = "카카오에서 받아온 AuthorizationCode", required = true, example = "네이버에서 받아온 코드") @RequestParam String code, @Parameter(description = "초대된 경우에만 있는 코드", required = false, example = "초대 코드") @RequestParam(required = false) String inviteCode) { - System.out.println(code); - System.out.println("카카오 로그인"); - try { - return ResponseEntity.ok(authService.loginWithCode(AuthProvider.KAKAO, code, inviteCode)); - } catch (Exception e) { - System.out.println(e.getMessage()); - return ResponseEntity.badRequest().build(); - } + return ResponseEntity.ok(authService.loginWithCode(AuthProvider.KAKAO, code, inviteCode)); } @PostMapping(value = "/naver", produces = MediaType.APPLICATION_JSON_VALUE) @@ -69,20 +64,15 @@ public ResponseEntity loginWithKakaoCode( + "초대받은 경우만 있는 토큰으로 네이버를 통한 로그인 인증을 처리합니다. 초대 토큰이 없는 경우에는 `inviteToken` 없이 요청합니다.") @ApiResponse(responseCode = "200", description = "Successful operation", content = @Content(schema = @Schema(implementation = TokenResponse.class))) + @ApiErrorExceptionsExample(LoginAuthExceptionDocs.class) public ResponseEntity loginWithNaverCode(@RequestParam String code, @RequestParam(required = false) String inviteCode) { - try { - return ResponseEntity.ok(authService.loginWithCode(AuthProvider.NAVER, code, inviteCode)); - } catch (Exception e) { - System.out.println(e.getMessage()); - return ResponseEntity.badRequest().build(); - } + return ResponseEntity.ok(authService.loginWithCode(AuthProvider.NAVER, code, inviteCode)); } - @PostMapping(value = "/refresh", produces = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(value = "/refresh") @Operation(summary = "AccessToken 갱신", description = "Refresh Token을 통해 AccessToken을 갱신합니다.") - @ApiResponse(responseCode = "200", description = "Successful operation", - content = @Content(schema = @Schema(implementation = RefreshTokenResponse.class))) + @ApiErrorExceptionsExample(RefreshAuthExceptionDocs.class) public ResponseEntity refresh(@RequestHeader("Refresh") String refreshToken) { return ResponseEntity.ok(authService.generateAccessToken(refreshToken)); } diff --git a/src/main/java/capstone/relation/api/auth/docs/BasicAuthExceptionDocs.java b/src/main/java/capstone/relation/api/auth/docs/BasicAuthExceptionDocs.java new file mode 100644 index 0000000..5a9cae9 --- /dev/null +++ b/src/main/java/capstone/relation/api/auth/docs/BasicAuthExceptionDocs.java @@ -0,0 +1,18 @@ +package capstone.relation.api.auth.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; + +@ExceptionDoc +public class BasicAuthExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} diff --git a/src/main/java/capstone/relation/api/auth/docs/LoginAuthExceptionDocs.java b/src/main/java/capstone/relation/api/auth/docs/LoginAuthExceptionDocs.java new file mode 100644 index 0000000..171636e --- /dev/null +++ b/src/main/java/capstone/relation/api/auth/docs/LoginAuthExceptionDocs.java @@ -0,0 +1,17 @@ +package capstone.relation.api.auth.docs; + +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.exception.GlobalErrorCode; +import capstone.relation.global.exception.GlobalException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; + +@ExceptionDoc +public class LoginAuthExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("로그인 서버에 보내는 요청에 실패한 경우 발생합니다.") + public GlobalCodeException 서버_요청_오류 = new GlobalException(GlobalErrorCode.OTHER_SERVER_BAD_REQUEST); + + @ExplainError("카카오 서버에 보낸 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 서버_토큰_만료 = new GlobalException(GlobalErrorCode.OTHER_SERVER_EXPIRED_TOKEN); +} diff --git a/src/main/java/capstone/relation/api/auth/docs/RefreshAuthExceptionDocs.java b/src/main/java/capstone/relation/api/auth/docs/RefreshAuthExceptionDocs.java new file mode 100644 index 0000000..a0515ac --- /dev/null +++ b/src/main/java/capstone/relation/api/auth/docs/RefreshAuthExceptionDocs.java @@ -0,0 +1,16 @@ +package capstone.relation.api.auth.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; + +@ExceptionDoc +public class RefreshAuthExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("리프레시 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 리프레시_토큰_만료 = new AuthException(AuthErrorCode.REFRESH_TOKEN_EXPIRED); + @ExplainError("리프레시 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 리프레시_토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); +} diff --git a/src/main/java/capstone/relation/api/auth/exception/AuthErrorCode.java b/src/main/java/capstone/relation/api/auth/exception/AuthErrorCode.java index 4a7deee..a3bcc50 100644 --- a/src/main/java/capstone/relation/api/auth/exception/AuthErrorCode.java +++ b/src/main/java/capstone/relation/api/auth/exception/AuthErrorCode.java @@ -1,21 +1,43 @@ package capstone.relation.api.auth.exception; -import org.springframework.http.HttpStatus; +import static capstone.relation.global.consts.JoEunStatic.*; +import java.lang.reflect.Field; +import java.util.Objects; + +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.dto.ErrorReason; +import capstone.relation.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; import lombok.Getter; @Getter -public enum AuthErrorCode { +@AllArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { + + @ExplainError("accessToken 만료시 발생하는 오류입니다.") + TOKEN_EXPIRED(UNAUTHORIZED, "AUTH_401_1", "인증 시간이 만료되었습니다. 인증토큰을 재 발급 해주세요"), + @ExplainError("인증 토큰이 잘못됐을 때 발생하는 오류입니다.") + INVALID_TOKEN(UNAUTHORIZED, "AUTH_401_2", "잘못된 토큰입니다. 재 로그인 해주세요"), - INVALID_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "access token이 이미 만료되었거나 올바르지 않습니다."), - INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "refresh token이 이미 만료되었거나 올바르지 않습니다."), - INVALID_WORKSPACE_STATE_USER(HttpStatus.BAD_REQUEST, "해당 워크스페이스에 가입되어 있지 않은 사용자입니다."); + @ExplainError("refreshToken 만료시 발생하는 오류입니다.") + REFRESH_TOKEN_EXPIRED(FORBIDDEN, "AUTH_403_1", "인증 시간이 만료되었습니다. 재 로그인 해주세요."), + @ExplainError("헤더에 올바른 accessToken을 담지않았을 때 발생하는 오류(형식 불일치 등)") + ACCESS_TOKEN_NOT_EXIST(FORBIDDEN, "AUTH_403_2", "알맞은 accessToken을 넣어주세요."); - private final HttpStatus httpStatus; - private final String message; + private final Integer status; + private final String code; + private final String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.builder().reason(reason).code(code).status(status).build(); + } - AuthErrorCode(HttpStatus httpStatus, String message) { - this.httpStatus = httpStatus; - this.message = message; + @Override + public String getExplainError() throws NoSuchFieldException { + Field field = this.getClass().getField(this.name()); + ExplainError annotation = field.getAnnotation(ExplainError.class); + return Objects.nonNull(annotation) ? annotation.value() : this.getReason(); } } diff --git a/src/main/java/capstone/relation/api/auth/exception/AuthException.java b/src/main/java/capstone/relation/api/auth/exception/AuthException.java index 9b106d6..befaed8 100644 --- a/src/main/java/capstone/relation/api/auth/exception/AuthException.java +++ b/src/main/java/capstone/relation/api/auth/exception/AuthException.java @@ -1,20 +1,13 @@ package capstone.relation.api.auth.exception; +import capstone.relation.global.exception.GlobalCodeException; import lombok.Getter; @Getter -public class AuthException extends RuntimeException { - - private final AuthErrorCode errorCode; +public class AuthException extends GlobalCodeException { public AuthException(AuthErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - } - - public AuthException(AuthErrorCode errorCode, String message) { - super(errorCode.getMessage() + " : " + message); - this.errorCode = errorCode; + super(errorCode); } } diff --git a/src/main/java/capstone/relation/api/auth/exception/KakaoKauthErrorCode.java b/src/main/java/capstone/relation/api/auth/exception/KakaoKauthErrorCode.java new file mode 100644 index 0000000..49b0496 --- /dev/null +++ b/src/main/java/capstone/relation/api/auth/exception/KakaoKauthErrorCode.java @@ -0,0 +1,63 @@ +package capstone.relation.api.auth.exception; + +import static capstone.relation.global.consts.JoEunStatic.*; + +import java.lang.reflect.Field; +import java.util.Objects; + +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.dto.ErrorReason; +import capstone.relation.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum KakaoKauthErrorCode implements BaseErrorCode { + KOE101(BAD_REQUEST, "KAKAO_KOE101", "invalid_client", "잘못된 앱 키 타입을 사용하거나 앱 키에 오타가 있을 경우"), + KOE009(BAD_REQUEST, "KAKAO_KOE009", "misconfigured", "등록되지 않은 플랫폼에서 액세스 토큰을 요청 하는 경우"), + KOE010( + BAD_REQUEST, + "KAKAO_KOE101", + "invalid_client", + "클라이언트 시크릿(Client secret) 기능을 사용하는 앱에서 토큰 요청 시 client_secret 값을 전달하지 않거나 정확하지 않은 값을 전달하는 경우"), + KOE303( + BAD_REQUEST, + "KAKAO_KOE303", + "invalid_grant", + "인가 코드 요청 시 사용한 redirect_uri와 액세스 토큰 요청 시 사용한 redirect_uri가 다른 경우"), + KOE319(BAD_REQUEST, "KAKAO_KOE319", "invalid_grant", "토큰 갱신 요청 시 리프레시 토큰을 전달하지 않는 경우"), + KOE320( + BAD_REQUEST, + "KAKAO_KOE320", + "invalid_grant", + "동일한 인가 코드를 두 번 이상 사용하거나, 이미 만료된 인가 코드를 사용한 경우, 혹은 인가 코드를 찾을 수 없는 경우"), + KOE322( + BAD_REQUEST, + "KAKAO_KOE322", + "invalid_grant", + "refresh_token을 찾을 수 없거나 이미 만료된 리프레시 토큰을 사용한 경우"), + KOE_INVALID_REQUEST(BAD_REQUEST, "KAKAO_KOE_INVALID_REQUEST", "invalid_request", "잘못된 요청인 경우"), + KOE400(BAD_REQUEST, "KAKAO_KOE400", "invalid_token", "ID 토큰 값이 전달되지 않았거나 올바른 형식이 아닌 ID 토큰인 경우"), + KOE401(BAD_REQUEST, "KAKAO_KOE401", "invalid_token", "ID 토큰을 발급한 인증 기관(iss)이 카카오 인증 서버"), + KOE402(BAD_REQUEST, "KAKAO_KOE402", "invalid_token", "서명이 올바르지 않아 유효한 ID 토큰이 아닌 경우"), + KOE403(BAD_REQUEST, "KAKAO_KOE403", "invalid_token", "만료된 ID 토큰인 경우"); + + private Integer status; + private String errorCode; + private String error; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.builder().status(status).code(errorCode).reason(reason).build(); + } + + @Override + public String getExplainError() throws NoSuchFieldException { + Field field = this.getClass().getField(this.name()); + ExplainError annotation = field.getAnnotation(ExplainError.class); + return Objects.nonNull(annotation) ? annotation.value() : this.getReason(); + } +} + diff --git a/src/main/java/capstone/relation/api/auth/jwt/TokenProvider.java b/src/main/java/capstone/relation/api/auth/jwt/TokenProvider.java index 411fb64..8aec9d5 100644 --- a/src/main/java/capstone/relation/api/auth/jwt/TokenProvider.java +++ b/src/main/java/capstone/relation/api/auth/jwt/TokenProvider.java @@ -7,19 +7,21 @@ import java.util.Collections; import java.util.Date; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; -import org.springframework.web.server.ResponseStatusException; +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; import capstone.relation.api.auth.jwt.refreshtoken.CollectionRefreshTokenRepository; import capstone.relation.api.auth.jwt.refreshtoken.RefreshToken; import capstone.relation.api.auth.jwt.refreshtoken.RefreshTokenRepository; import capstone.relation.api.auth.jwt.response.TokenResponse; import capstone.relation.user.domain.Role; import capstone.relation.user.domain.User; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -83,7 +85,7 @@ public boolean validateToken(String accessToken) { public String generateAccessTokenByRefreshToken(String refreshTokenKey) { long now = (new Date().getTime()); RefreshToken refreshToken = refreshTokenRepository.findByKey(refreshTokenKey) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "유효하지 않은 리프레시 토큰 입니다.")); + .orElseThrow(() -> new AuthException(AuthErrorCode.INVALID_TOKEN)); User user = refreshToken.user(); Date accessTokenExpiredDate = new Date(now + jwtProperties.getAccessTokenExpireTime()); return generateAccessToken(user, accessTokenExpiredDate); @@ -116,9 +118,9 @@ public String getWorkSpaceIdByInviteCode(String inviteCode) { Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(inviteToken).getBody(); return claims.getSubject(); } catch (ExpiredJwtException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "만료된 초대 코드입니다."); + throw new WorkSpaceException(WorkSpaceErrorCode.EXPIRED_INVITE_CODE); } catch (Exception e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "유효하지 않은 초대 코드입니다."); + throw new WorkSpaceException(WorkSpaceErrorCode.INVALID_INVITE_CODE); } } @@ -143,7 +145,7 @@ private Claims decodeAccessToken(String accessToken) { try { return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); } catch (ExpiredJwtException expiredJwtException) { - throw new IllegalStateException("만료된 토큰입니다."); + throw new AuthException(AuthErrorCode.TOKEN_EXPIRED); } } } diff --git a/src/main/java/capstone/relation/api/auth/service/AuthService.java b/src/main/java/capstone/relation/api/auth/service/AuthService.java index 215ac23..cb15bed 100644 --- a/src/main/java/capstone/relation/api/auth/service/AuthService.java +++ b/src/main/java/capstone/relation/api/auth/service/AuthService.java @@ -4,11 +4,9 @@ import java.util.Map; import java.util.Optional; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; import capstone.relation.api.auth.AuthProvider; import capstone.relation.api.auth.jwt.TokenProvider; @@ -17,6 +15,8 @@ import capstone.relation.api.auth.jwt.response.WorkspaceStateType; import capstone.relation.api.auth.oauth.provider.OAuthUserProvider; import capstone.relation.user.domain.User; +import capstone.relation.user.exception.UserErrorCode; +import capstone.relation.user.exception.UserException; import capstone.relation.user.repository.UserRepository; import capstone.relation.workspace.WorkSpace; import capstone.relation.workspace.service.InvitationService; @@ -51,13 +51,13 @@ public TokenResponse login(AuthProvider authProvider, String accessToken) { public TokenResponse loginWithCode(AuthProvider authProvider, String code, String inviteCode) { String accessToken = getToken(authProvider, code); TokenResponse response = login(authProvider, accessToken); - if (inviteCode == null || inviteCode.isEmpty() || inviteCode.isBlank() || inviteCode.equals("null")) { + if (inviteCode == null || inviteCode.isEmpty() || inviteCode.isBlank() || inviteCode.equals("null")) return response; - } + Optional userOpt = userRepository.findById(response.getMemberId()); - if (userOpt.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found."); - } + if (userOpt.isEmpty()) + throw new UserException(UserErrorCode.USER_NOT_FOUND); + WorkSpace workSpace = invitationService.getWorkSpace(inviteCode); User user = userOpt.get(); if (user.getWorkSpace() != null) { diff --git a/src/main/java/capstone/relation/global/annotation/ApiErrorCodeExample.java b/src/main/java/capstone/relation/global/annotation/ApiErrorCodeExample.java new file mode 100644 index 0000000..1316b3b --- /dev/null +++ b/src/main/java/capstone/relation/global/annotation/ApiErrorCodeExample.java @@ -0,0 +1,14 @@ +package capstone.relation.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import capstone.relation.global.exception.BaseErrorCode; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorCodeExample { + Class value(); +} diff --git a/src/main/java/capstone/relation/global/annotation/ApiErrorExceptionsExample.java b/src/main/java/capstone/relation/global/annotation/ApiErrorExceptionsExample.java new file mode 100644 index 0000000..5e6f63c --- /dev/null +++ b/src/main/java/capstone/relation/global/annotation/ApiErrorExceptionsExample.java @@ -0,0 +1,14 @@ +package capstone.relation.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import capstone.relation.global.interfaces.SwaggerExampleExceptions; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorExceptionsExample { + Class value(); +} \ No newline at end of file diff --git a/src/main/java/capstone/relation/global/annotation/DevelopOnlyApi.java b/src/main/java/capstone/relation/global/annotation/DevelopOnlyApi.java new file mode 100644 index 0000000..92ad515 --- /dev/null +++ b/src/main/java/capstone/relation/global/annotation/DevelopOnlyApi.java @@ -0,0 +1,11 @@ +package capstone.relation.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface DevelopOnlyApi { +} \ No newline at end of file diff --git a/src/main/java/capstone/relation/global/annotation/ExceptionDoc.java b/src/main/java/capstone/relation/global/annotation/ExceptionDoc.java new file mode 100644 index 0000000..ac356a8 --- /dev/null +++ b/src/main/java/capstone/relation/global/annotation/ExceptionDoc.java @@ -0,0 +1,19 @@ +package capstone.relation.global.annotation; + +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; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface ExceptionDoc { + @AliasFor(annotation = Component.class) + String value() default ""; +} \ No newline at end of file diff --git a/src/main/java/capstone/relation/global/annotation/ExplainError.java b/src/main/java/capstone/relation/global/annotation/ExplainError.java new file mode 100644 index 0000000..3d79c62 --- /dev/null +++ b/src/main/java/capstone/relation/global/annotation/ExplainError.java @@ -0,0 +1,17 @@ +package capstone.relation.global.annotation; + +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; + +import org.springframework.stereotype.Component; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface ExplainError { + String value() default ""; +} diff --git a/src/main/java/capstone/relation/global/aop/ApiBlockingAspect.java b/src/main/java/capstone/relation/global/aop/ApiBlockingAspect.java new file mode 100644 index 0000000..30a0de6 --- /dev/null +++ b/src/main/java/capstone/relation/global/aop/ApiBlockingAspect.java @@ -0,0 +1,28 @@ +package capstone.relation.global.aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import capstone.relation.global.exception.GlobalDynamicException; +import capstone.relation.global.helper.SpringEnvironmentHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class ApiBlockingAspect { + + private final SpringEnvironmentHelper springEnvironmentHelper; + + @Around("@annotation(capstone.relation.global.annotation.DevelopOnlyApi)") + public Object checkApiAcceptingCondition(ProceedingJoinPoint joinPoint) throws Throwable { + if (springEnvironmentHelper.isProdProfile()) { + throw new GlobalDynamicException(405, "Blocked Api", "not working api in production"); + } + return joinPoint.proceed(); + } +} diff --git a/src/main/java/capstone/relation/global/config/ExampleHolder.java b/src/main/java/capstone/relation/global/config/ExampleHolder.java new file mode 100644 index 0000000..721d718 --- /dev/null +++ b/src/main/java/capstone/relation/global/config/ExampleHolder.java @@ -0,0 +1,13 @@ +package capstone.relation.global.config; + +import io.swagger.v3.oas.models.examples.Example; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExampleHolder { + private Example holder; + private String name; + private int code; +} diff --git a/src/main/java/capstone/relation/global/config/SwaggerConfig.java b/src/main/java/capstone/relation/global/config/SwaggerConfig.java index d0c7268..9c43930 100644 --- a/src/main/java/capstone/relation/global/config/SwaggerConfig.java +++ b/src/main/java/capstone/relation/global/config/SwaggerConfig.java @@ -1,17 +1,47 @@ package capstone.relation.global.config; +import static java.util.stream.Collectors.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.HandlerMethod; +import capstone.relation.global.annotation.ApiErrorCodeExample; +import capstone.relation.global.annotation.ApiErrorExceptionsExample; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.dto.ErrorReason; +import capstone.relation.global.dto.ErrorResponse; +import capstone.relation.global.exception.BaseErrorCode; +import capstone.relation.global.exception.GlobalCodeException; +import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; +import lombok.RequiredArgsConstructor; @Configuration +@RequiredArgsConstructor public class SwaggerConfig { + private final ApplicationContext applicationContext; @Bean public OpenAPI openAPI() { @@ -40,5 +70,155 @@ public OpenAPI openAPI() { .addSecurityItem(securityRequirement) .components(components); } + + /** + * Swagger 문서의 각 API 동작(Operation)을 사용자 정의하기 위해 사용 + * ApiErrorCodeExample 어노테이션을 찾습니다. 이 어노테이션은 특정 메서드에 정의된 에러 코드 예시를 설정하는 데 사용됩니다. + */ + @Bean + public OperationCustomizer customize() { + return (Operation operation, HandlerMethod handlerMethod) -> { + ApiErrorCodeExample apiErrorCodeExample = + handlerMethod.getMethodAnnotation(ApiErrorCodeExample.class); + ApiErrorExceptionsExample apiErrorExceptionsExample = + handlerMethod.getMethodAnnotation(ApiErrorExceptionsExample.class); + List tags = getTags(handlerMethod); + + // 태그 중복 설정시 제일 구체적인 값만 태그로 설정 + if (!tags.isEmpty()) + operation.setTags(Collections.singletonList(tags.get(0))); + // ApiErrorExceptionsExample 어노테이션 단 메소드 적용 + if (apiErrorExceptionsExample != null) { + generateExceptionResponseExample(operation, apiErrorExceptionsExample.value()); + } + // ApiErrorCodeExample 어노테이션 단 메소드 적용 + if (apiErrorCodeExample != null) + generateErrorCodeResponseExample(operation, apiErrorCodeExample.value()); + + return operation; + }; + } + + /** + * BaseErrorCode 타입의 이넘값들을 문서화 시킵니다. ExplainError 어노테이션으로 부가설명을 붙일수있습니다. 필드들을 가져와서 예시 에러 객체를 + * 동적으로 생성해서 예시값으로 붙입니다. + */ + private void generateErrorCodeResponseExample( + Operation operation, Class type) { + ApiResponses responses = operation.getResponses(); + + BaseErrorCode[] errorCodes = type.getEnumConstants(); + + Map> statusWithExampleHolders = + Arrays.stream(errorCodes) + .map( + baseErrorCode -> { + try { + ErrorReason errorReason = baseErrorCode.getErrorReason(); + return ExampleHolder.builder() + .holder( + getSwaggerExample( + baseErrorCode.getExplainError(), + errorReason)) + .code(errorReason.getStatus()) + .name(errorReason.getCode()) + .build(); + } catch (NoSuchFieldException e) { + throw new RuntimeException(e); + } + }) + .collect(groupingBy(ExampleHolder::getCode)); + + addExamplesToResponses(responses, statusWithExampleHolders); + } + + /** + * SwaggerExampleExceptions 타입의 클래스를 문서화 시킵니다. SwaggerExampleExceptions 타입의 클래스는 필드로 + * GlobalCodeException 타입을 가지며, GlobalCodeException 의 errorReason 와,ExplainError 의 설명을 + * 문서화시킵니다. + */ + private void generateExceptionResponseExample(Operation operation, Class type) { + ApiResponses responses = operation.getResponses(); + + // ----------------생성 + Object bean = applicationContext.getBean(type); + Field[] declaredFields = bean.getClass().getDeclaredFields(); + Map> statusWithExampleHolders = + Arrays.stream(declaredFields) + .filter(field -> field.getAnnotation(ExplainError.class) != null) + .filter(field -> field.getType() == GlobalCodeException.class) + .map( + field -> { + try { + GlobalCodeException exception = + (GlobalCodeException)field.get(bean); + ExplainError annotation = + field.getAnnotation(ExplainError.class); + String value = annotation.value(); + ErrorReason errorReason = exception.getErrorReason(); + return ExampleHolder.builder() + .holder(getSwaggerExample(value, errorReason)) + .code(errorReason.getStatus()) + .name(field.getName()) + .build(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + }) + .collect(groupingBy(ExampleHolder::getCode)); + + // -------------------------- 콘텐츠 세팅 코드별로 진행 + addExamplesToResponses(responses, statusWithExampleHolders); + } + + /** + * : 주어진 ErrorReason을 사용하여 ErrorResponse 객체를 생성합니다. 이 객체는 예시 응답에 포함됩니다. + */ + private Example getSwaggerExample(String value, ErrorReason errorReason) { + ErrorResponse errorResponse = new ErrorResponse(errorReason, "요청시 패스정보입니다."); + Example example = new Example(); + example.description(value); + example.setValue(errorResponse); + return example; + } + + /** + * 상태 코드별로 예시를 API 응답에 추가합니다. + */ + private void addExamplesToResponses( + ApiResponses responses, Map> statusWithExampleHolders) { + statusWithExampleHolders.forEach( + (status, v) -> { + Content content = new Content(); + MediaType mediaType = new MediaType(); + ApiResponse apiResponse = new ApiResponse(); + v.forEach( + exampleHolder -> { + mediaType.addExamples( + exampleHolder.getName(), exampleHolder.getHolder()); + }); + content.addMediaType("application/json", mediaType); + apiResponse.setContent(content); + responses.addApiResponse(status.toString(), apiResponse); + }); + } + + /** + * API 메서드 및 클래스에 정의된 Tag 어노테이션을 가져와 태그 이름을 추출하고 리스트에 추가 합니다. + */ + private static List getTags(HandlerMethod handlerMethod) { + List tags = new ArrayList<>(); + + Tag[] methodTags = handlerMethod.getMethod().getAnnotationsByType(Tag.class); + List methodTagStrings = + Arrays.stream(methodTags).map(Tag::name).collect(Collectors.toList()); + + Tag[] classTags = handlerMethod.getClass().getAnnotationsByType(Tag.class); + List classTagStrings = + Arrays.stream(classTags).map(Tag::name).collect(Collectors.toList()); + tags.addAll(methodTagStrings); + tags.addAll(classTagStrings); + return tags; + } } diff --git a/src/main/java/capstone/relation/global/consts/JoEunStatic.java b/src/main/java/capstone/relation/global/consts/JoEunStatic.java new file mode 100644 index 0000000..a55a904 --- /dev/null +++ b/src/main/java/capstone/relation/global/consts/JoEunStatic.java @@ -0,0 +1,29 @@ +package capstone.relation.global.consts; + +public class JoEunStatic { + public static final String AUTH_HEADER = "Authorization"; + public static final String BEARER = "Bearer "; + public static final String ACCESS_TOKEN = "ACCESS_TOKEN"; + public static final String REFRESH_TOKEN = "REFRESH_TOKEN"; + + public static final int MILLI_TO_SECOND = 1000; + public static final int BAD_REQUEST = 400; + public static final int UNAUTHORIZED = 401; + public static final int FORBIDDEN = 403; + public static final int NOT_FOUND = 404; + + public static final int CONFLICT = 409; + public static final int INTERNAL_SERVER = 500; + + public static final Long NO_START_NUMBER = 1000000L; + public static final Long MINIMUM_PAYMENT_WON = 1000L; + public static final Long ZERO = 0L; + + public static final String KAKAO_OAUTH_QUERY_STRING = + "/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code"; + + public static final String[] SwaggerPatterns = { + "/swagger-resources/**", "/swagger-ui/**", "/v3/api-docs/**", "/v3/api-docs", + }; +} + diff --git a/src/main/java/capstone/relation/global/document/ExampleController.java b/src/main/java/capstone/relation/global/document/ExampleController.java new file mode 100644 index 0000000..c9123fd --- /dev/null +++ b/src/main/java/capstone/relation/global/document/ExampleController.java @@ -0,0 +1,71 @@ +package capstone.relation.global.document; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.global.annotation.ApiErrorCodeExample; +import capstone.relation.global.annotation.DevelopOnlyApi; +import capstone.relation.global.exception.GlobalErrorCode; +import capstone.relation.meeting.exception.MeetingErrorCode; +import capstone.relation.user.exception.UserErrorCode; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/example") +@RequiredArgsConstructor +@Tag(name = "Exception Document", description = "예제 에러코드 문서화") +public class ExampleController { + @GetMapping("/global") + @DevelopOnlyApi + @ApiErrorCodeExample(GlobalErrorCode.class) + @Operation(summary = "글로벌 (aop, 서버 내부 오류등) 관련 에러 코드 나열") + public void example() { + } + + @GetMapping("/auth") + @DevelopOnlyApi + @Operation(summary = "인증 도메인 관련 에러 코드 나열") + @ApiErrorCodeExample(AuthErrorCode.class) + public void getAuthErrorCode() { + } + + @GetMapping("/user") + @DevelopOnlyApi + @Operation(summary = "유저 도메인 관련 에러 코드 나열") + @ApiErrorCodeExample(UserErrorCode.class) + public void getUserErrorCode() { + } + + @GetMapping("/workspace") + @DevelopOnlyApi + @Operation(summary = "워크스페이스 도메인 관련 에러 코드 나열") + @ApiErrorCodeExample(WorkSpaceErrorCode.class) + public void getWorkSpaceErrorCode() { + } + + @GetMapping("/meeting") + @DevelopOnlyApi + @Operation(summary = "화상회의 도메인 관련 에러 코드 나열") + @ApiErrorCodeExample(MeetingErrorCode.class) + public void getMeetingErrorCode() { + } + + // @GetMapping("/chat") + // @DevelopOnlyApi + // @Operation(summary = "채팅 도메인 관련 에러 코드 나열 (이건 소켓으로 처리됩니다.") + // @ApiErrorCodeExample(ChatErrorCode.class) + // public void getChatErrorCode() { + // } + + // @GetMapping("/socket") + // @DevelopOnlyApi + // @Operation(summary = "소켓 도메인 관련 에러 코드 나열") + // @ApiErrorCodeExample(SocketErrorCode.class) + // public void getSocketErrorCode() { + // } +} diff --git a/src/main/java/capstone/relation/global/dto/ErrorReason.java b/src/main/java/capstone/relation/global/dto/ErrorReason.java new file mode 100644 index 0000000..4b453dd --- /dev/null +++ b/src/main/java/capstone/relation/global/dto/ErrorReason.java @@ -0,0 +1,13 @@ +package capstone.relation.global.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ErrorReason { + private final Integer status; + private final String code; + private final String reason; +} + diff --git a/src/main/java/capstone/relation/global/dto/ErrorResponse.java b/src/main/java/capstone/relation/global/dto/ErrorResponse.java new file mode 100644 index 0000000..f16c08d --- /dev/null +++ b/src/main/java/capstone/relation/global/dto/ErrorResponse.java @@ -0,0 +1,33 @@ +package capstone.relation.global.dto; + +import java.time.LocalDateTime; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + + private final boolean success = false; + private final int status; + private final String code; + private final String reason; + private final LocalDateTime timeStamp; + + private final String path; + + public ErrorResponse(ErrorReason errorReason, String path) { + this.status = errorReason.getStatus(); + this.code = errorReason.getCode(); + this.reason = errorReason.getReason(); + this.timeStamp = LocalDateTime.now(); + this.path = path; + } + + public ErrorResponse(int status, String code, String reason, String path) { + this.status = status; + this.code = code; + this.reason = reason; + this.timeStamp = LocalDateTime.now(); + this.path = path; + } +} diff --git a/src/main/java/capstone/relation/global/exception/BaseErrorCode.java b/src/main/java/capstone/relation/global/exception/BaseErrorCode.java new file mode 100644 index 0000000..015b41a --- /dev/null +++ b/src/main/java/capstone/relation/global/exception/BaseErrorCode.java @@ -0,0 +1,9 @@ +package capstone.relation.global.exception; + +import capstone.relation.global.dto.ErrorReason; + +public interface BaseErrorCode { + public ErrorReason getErrorReason(); + + String getExplainError() throws NoSuchFieldException; +} diff --git a/src/main/java/capstone/relation/global/exception/GlobalCodeException.java b/src/main/java/capstone/relation/global/exception/GlobalCodeException.java new file mode 100644 index 0000000..ce7d420 --- /dev/null +++ b/src/main/java/capstone/relation/global/exception/GlobalCodeException.java @@ -0,0 +1,15 @@ +package capstone.relation.global.exception; + +import capstone.relation.global.dto.ErrorReason; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GlobalCodeException extends RuntimeException { + private BaseErrorCode errorCode; + + public ErrorReason getErrorReason() { + return this.errorCode.getErrorReason(); + } +} diff --git a/src/main/java/capstone/relation/global/exception/GlobalDynamicException.java b/src/main/java/capstone/relation/global/exception/GlobalDynamicException.java new file mode 100644 index 0000000..6d02536 --- /dev/null +++ b/src/main/java/capstone/relation/global/exception/GlobalDynamicException.java @@ -0,0 +1,12 @@ +package capstone.relation.global.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GlobalDynamicException extends RuntimeException { + private final int status; + private final String code; + private final String reason; +} \ No newline at end of file diff --git a/src/main/java/capstone/relation/global/exception/GlobalErrorCode.java b/src/main/java/capstone/relation/global/exception/GlobalErrorCode.java new file mode 100644 index 0000000..20d5735 --- /dev/null +++ b/src/main/java/capstone/relation/global/exception/GlobalErrorCode.java @@ -0,0 +1,56 @@ +package capstone.relation.global.exception; + +import static capstone.relation.global.consts.JoEunStatic.*; + +import java.lang.reflect.Field; +import java.util.Objects; + +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.dto.ErrorReason; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 글로벌 관련 예외 코드들이 나온 곳입니다. 인증 , global, aop 종류등 도메인 제외한 exception 코드들이 모이는 곳입니다. 도메인 관련 Exception + * code 들은 도메인 내부 exception 패키지에 위치시키면 됩니다. + */ +@Getter +@AllArgsConstructor +public enum GlobalErrorCode implements BaseErrorCode { + @ExplainError("백엔드에서 예시로만든 에러입니다. 개발용") + EXAMPLE_NOT_FOUND(NOT_FOUND, "EXAMPLE_404_1", "예시를 찾을 수 없는 오류입니다."), + + @ExplainError("밸리데이션 (검증 과정 수행속 ) 발생하는 오류입니다.") + ARGUMENT_NOT_VALID_ERROR(BAD_REQUEST, "GLOBAL_400_1", "검증 오류"), + + @ExplainError("500번대 알수없는 오류입니다. 서버 관리자에게 문의 주세요") + INTERNAL_SERVER_ERROR(INTERNAL_SERVER, "GLOBAL_500_1", "서버 오류. 관리자에게 문의 부탁드립니다."), + + OTHER_SERVER_BAD_REQUEST(BAD_REQUEST, "FEIGN_400_1", "다른 서버로 한 요청이 Bad Request 입니다."), + OTHER_SERVER_UNAUTHORIZED(BAD_REQUEST, "FEIGN_400_2", "다른 서버로 한 요청이 Unauthorized 입니다."), + OTHER_SERVER_FORBIDDEN(BAD_REQUEST, "FEIGN_400_3", "다른 서버로 한 요청이 Forbidden 입니다."), + OTHER_SERVER_EXPIRED_TOKEN(BAD_REQUEST, "FEIGN_400_4", "다른 서버로 한 요청이 Expired Token 입니다."), + OTHER_SERVER_NOT_FOUND(BAD_REQUEST, "FEIGN_400_5", "다른 서버로 한 요청이 Not Found 입니다."), + OTHER_SERVER_INTERNAL_SERVER_ERROR( + BAD_REQUEST, "FEIGN_400_6", "다른 서버로 한 요청이 Internal Server Error 입니다."), + SECURITY_CONTEXT_NOT_FOUND(500, "GLOBAL_500_2", "SecurityContext를 찾을 수 없습니다."), + + BAD_FILE_EXTENSION(BAD_REQUEST, "FILE_400_1", "파일 확장자가 잘못 되었습니다."), + TOO_MANY_REQUEST(429, "GLOBAL_429_1", "과도한 요청을 보내셨습니다. 잠시 기다려 주세요."); + private Integer status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.builder().reason(reason).code(code).status(status).build(); + } + + @Override + public String getExplainError() throws NoSuchFieldException { + Field field = this.getClass().getField(this.name()); + ExplainError annotation = field.getAnnotation(ExplainError.class); + return Objects.nonNull(annotation) ? annotation.value() : this.getReason(); + } +} + diff --git a/src/main/java/capstone/relation/global/exception/GlobalException.java b/src/main/java/capstone/relation/global/exception/GlobalException.java new file mode 100644 index 0000000..acab44d --- /dev/null +++ b/src/main/java/capstone/relation/global/exception/GlobalException.java @@ -0,0 +1,7 @@ +package capstone.relation.global.exception; + +public class GlobalException extends GlobalCodeException { + public GlobalException(GlobalErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/capstone/relation/global/exception/GlobalExceptionHandler.java b/src/main/java/capstone/relation/global/exception/GlobalExceptionHandler.java index ce12c7e..fc05b61 100644 --- a/src/main/java/capstone/relation/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/capstone/relation/global/exception/GlobalExceptionHandler.java @@ -1,38 +1,162 @@ package capstone.relation.global.exception; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import capstone.relation.global.dto.ErrorReason; +import capstone.relation.global.dto.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; @RestControllerAdvice -public class GlobalExceptionHandler { +@Slf4j +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + //TODO: 디코 알림 보내는 거 추가 - @ExceptionHandler(ResponseStatusException.class) //커스텀 예외처리 - public ResponseEntity handleResponseStatusException(ResponseStatusException ex, WebRequest request) { - return new ResponseEntity<>(ex.getReason(), ex.getStatusCode()); + // ResponseEntityExceptionHandler를 상속받아서 예외처리를 할 수 있다. + @Override + protected ResponseEntity handleExceptionInternal( + Exception ex, Object body, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + ServletWebRequest servletWebRequest = (ServletWebRequest)request; + HttpServletRequest httpServletRequest = servletWebRequest.getRequest(); // 예외가 발생한 URL과 같은 요청에 대한 세부 정보를 추출 + String url = httpServletRequest.getRequestURL().toString(); + + HttpStatus httpStatus = (HttpStatus)status; + ErrorResponse errorResponse = + new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase(), ex.getMessage(), + url); // 사용자 정의 ErrorResponse 객체를 생성 + return super.handleExceptionInternal(ex, errorResponse, headers, status, request); } - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex, - HttpServletRequest request) { - ProblemDetail problemDetail = ProblemDetailCreator.create(ex, request, HttpStatus.BAD_REQUEST); - problemDetail.setTitle("Invalid Argument"); - problemDetail.setStatus(HttpStatus.BAD_REQUEST.value()); - problemDetail.setDetail(ex.getMessage()); + //주로 요청 본문이 유효성 검사를 통과하지 못할 때 발생합니다 (예: @Valid 어노테이션 사용 시) MethodArgumentNotValidException 예외를 처리하는 메서드 + @SneakyThrows // 메서드 선언부에 Throws 를 정의하지 않고도, 검사 된 예외를 Throw 할 수 있도록 하는 Lombok 에서 제공하는 어노테이션입 + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + List errors = ex.getBindingResult().getFieldErrors(); + ServletWebRequest servletWebRequest = (ServletWebRequest)request; + HttpServletRequest httpServletRequest = servletWebRequest.getRequest(); + String url = httpServletRequest.getRequestURL().toString(); - return ResponseEntity.badRequest().body(problemDetail); + Map fieldAndErrorMessages = + errors.stream() + .collect( + Collectors.toMap( + FieldError::getField, FieldError::getDefaultMessage)); + + String errorsToJsonString = new ObjectMapper().writeValueAsString(fieldAndErrorMessages); + + HttpStatus httpStatus = (HttpStatus)status; + ErrorResponse errorResponse = + new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase(), errorsToJsonString, url); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } + /** Request Param Validation 예외 처리 + * 유효성 검사 제약 조건이 위반되었을 때 발생합니다. (예: @NotNull, @Size, @Email 어노테이션 사용 시) + */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity constraintViolationExceptionHandler( + ConstraintViolationException ex, HttpServletRequest request) { + Map bindingErrors = new HashMap<>(); // 유효성 검사 실패 필드와 해당 오류 메시지를 저장하기 위한 맵을 생성 + // 예외 객체에서 유효성 검사 위반 항목들을 가져옴. + ex.getConstraintViolations() + .forEach( + constraintViolation -> { + //위반된 속성의 경로를 가져옵니다. 이 경로는 문자열로 변환되어 점(.)을 기준으로 분할됩니다 + List propertyPath = + List.of( + constraintViolation + .getPropertyPath() + .toString() + .split("\\.")); + // 마지막 요소를 추출하여 실제 필드 이름을 가져옵니다. 예를 들어, 경로가 user.address.street라면 street가 추출됩니다. + String path = + propertyPath.stream() + .skip(propertyPath.size() - 1L) + .findFirst() + .orElse(null); + //위반된 필드 이름과 해당 오류 메시지를 맵에 저장 + bindingErrors.put(path, constraintViolation.getMessage()); + }); + + ErrorReason errorReason = + ErrorReason.builder() + .code("BAD_REQUEST") + .status(400) + .reason(bindingErrors.toString()) + .build(); + ErrorResponse errorResponse = + new ErrorResponse(errorReason, request.getRequestURL().toString()); + return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus())) + .body(errorResponse); + } + + @ExceptionHandler(GlobalCodeException.class) + public ResponseEntity joEunCodeExceptionHandler( + GlobalCodeException e, HttpServletRequest request) { + BaseErrorCode code = e.getErrorCode(); + ErrorReason errorReason = code.getErrorReason(); + ErrorResponse errorResponse = + new ErrorResponse(errorReason, request.getRequestURL().toString()); + return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus())) + .body(errorResponse); + } + + @ExceptionHandler(GlobalDynamicException.class) + public ResponseEntity joEunDynamicExceptionHandler( + GlobalDynamicException e, HttpServletRequest request) { + ErrorResponse errorResponse = + new ErrorResponse( + e.getStatus(), + e.getCode(), + e.getReason(), + request.getRequestURL().toString()); + return ResponseEntity.status(HttpStatus.valueOf(e.getStatus())).body(errorResponse); + } + + //TODO: 이 경우 디코에 알림 가도록 구성해도 좋겠다. @ExceptionHandler(Exception.class) - public ResponseEntity handleAllException(Exception ex, HttpServletRequest request) { - ProblemDetail problemDetail = ProblemDetailCreator.create(ex, request, HttpStatus.BAD_REQUEST); + protected ResponseEntity handleException(Exception ex, HttpServletRequest request) + throws IOException { + ServletWebRequest servletWebRequest = (ServletWebRequest)request; + HttpServletRequest httpServletRequest = servletWebRequest.getRequest(); // 예외가 발생한 URL과 같은 요청에 대한 세부 정보를 추출 + String url = httpServletRequest.getRequestURL().toString(); + + log.error("INTERNAL_SERVER_ERROR", ex); + GlobalErrorCode internalServerError = GlobalErrorCode.INTERNAL_SERVER_ERROR; + ErrorResponse errorResponse = + new ErrorResponse( + internalServerError.getStatus(), + internalServerError.getCode(), + internalServerError.getReason(), + url); - return ResponseEntity.badRequest().body(problemDetail); + return ResponseEntity.status(HttpStatus.valueOf(internalServerError.getStatus())) + .body(errorResponse); } } diff --git a/src/main/java/capstone/relation/global/exception/ProblemDetailCreator.java b/src/main/java/capstone/relation/global/exception/ProblemDetailCreator.java deleted file mode 100644 index a866b8d..0000000 --- a/src/main/java/capstone/relation/global/exception/ProblemDetailCreator.java +++ /dev/null @@ -1,35 +0,0 @@ -package capstone.relation.global.exception; - -import java.net.URI; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.web.bind.MethodArgumentNotValidException; - -import jakarta.servlet.http.HttpServletRequest; - -public class ProblemDetailCreator { - private ProblemDetailCreator() { - } - - public static ProblemDetail create(Exception ex, HttpServletRequest request, HttpStatus status) { - ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, ex.getMessage()); - problemDetail.setInstance(URI.create(request.getRequestURI())); - problemDetail.setTitle(ex.getClass().getSimpleName()); - return problemDetail; - } - - public static ProblemDetail createValidationDetails(MethodArgumentNotValidException ex, HttpServletRequest request, - HttpStatus status) { - ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, ex.getMessage()); - problemDetail.setInstance(URI.create(request.getRequestURI())); - problemDetail.setTitle(ex.getClass().getSimpleName()); - - problemDetail.setProperty("validationError", ex.getBindingResult() - .getFieldErrors() - .stream() - .map(ValidationError::of) - .toList()); - return problemDetail; - } -} diff --git a/src/main/java/capstone/relation/global/exception/ValidationError.java b/src/main/java/capstone/relation/global/exception/ValidationError.java deleted file mode 100644 index 4fb8870..0000000 --- a/src/main/java/capstone/relation/global/exception/ValidationError.java +++ /dev/null @@ -1,18 +0,0 @@ -package capstone.relation.global.exception; - -import org.springframework.validation.FieldError; - -import lombok.Builder; - -@Builder -public record ValidationError( - String field, - String message -) { - public static ValidationError of(FieldError fieldError) { - return ValidationError.builder() - .field(fieldError.getField()) - .message(fieldError.getDefaultMessage()) - .build(); - } -} diff --git a/src/main/java/capstone/relation/global/helper/SpringEnvironmentHelper.java b/src/main/java/capstone/relation/global/helper/SpringEnvironmentHelper.java new file mode 100644 index 0000000..59213f8 --- /dev/null +++ b/src/main/java/capstone/relation/global/helper/SpringEnvironmentHelper.java @@ -0,0 +1,47 @@ +package capstone.relation.global.helper; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class SpringEnvironmentHelper { + + private final Environment environment; + + private final String PROD = "prod"; + private final String STAGING = "staging"; + private final String DEV = "dev"; + + private final List PROD_AND_STAGING = List.of("staging", "prod"); + + public Boolean isProdProfile() { + String[] activeProfiles = environment.getActiveProfiles(); + List currentProfile = Arrays.stream(activeProfiles).toList(); + return currentProfile.contains(PROD); + } + + public Boolean isStagingProfile() { + String[] activeProfiles = environment.getActiveProfiles(); + List currentProfile = Arrays.stream(activeProfiles).toList(); + return currentProfile.contains(STAGING); + } + + public Boolean isDevProfile() { + String[] activeProfiles = environment.getActiveProfiles(); + List currentProfile = Arrays.stream(activeProfiles).toList(); + return currentProfile.contains(DEV); + } + + public Boolean isProdAndStagingProfile() { + String[] activeProfiles = environment.getActiveProfiles(); + List currentProfile = Arrays.stream(activeProfiles).toList(); + return CollectionUtils.containsAny(PROD_AND_STAGING, currentProfile); + } +} diff --git a/src/main/java/capstone/relation/global/interfaces/SwaggerExampleExceptions.java b/src/main/java/capstone/relation/global/interfaces/SwaggerExampleExceptions.java new file mode 100644 index 0000000..e143995 --- /dev/null +++ b/src/main/java/capstone/relation/global/interfaces/SwaggerExampleExceptions.java @@ -0,0 +1,7 @@ +package capstone.relation.global.interfaces; + +import capstone.relation.global.annotation.ExceptionDoc; + +@ExceptionDoc +public interface SwaggerExampleExceptions { +} diff --git a/src/main/java/capstone/relation/meeting/controller/MeetRoomController.java b/src/main/java/capstone/relation/meeting/controller/MeetRoomController.java index 48cd2f0..5d5d139 100644 --- a/src/main/java/capstone/relation/meeting/controller/MeetRoomController.java +++ b/src/main/java/capstone/relation/meeting/controller/MeetRoomController.java @@ -7,11 +7,15 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import capstone.relation.global.annotation.ApiErrorExceptionsExample; import capstone.relation.global.util.SecurityUtil; +import capstone.relation.meeting.docs.CreateRoomExceptionDocs; +import capstone.relation.meeting.docs.LeaveRoomExceptionDocs; import capstone.relation.meeting.dto.request.CreateRoomDto; import capstone.relation.meeting.dto.response.JoinResponseDto; import capstone.relation.meeting.dto.response.MeetingRoomListDto; import capstone.relation.meeting.service.MeetRoomService; +import capstone.relation.workspace.docs.WorkspaceGetExceptionDocs; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -28,12 +32,14 @@ public class MeetRoomController { "새로운 회의방을 생성합니다. 생성된 회의방은 `/topic/{workSpaceId}/meetingRoomList`로 생성된 방에 대한 목록 발송이 이루어집니다.\n" + "방 생성자는 자동으로 방에 참여합니다.\n" ) + @ApiErrorExceptionsExample(CreateRoomExceptionDocs.class) public JoinResponseDto createRoom(@RequestBody CreateRoomDto createRoomDto) { return meetRoomService.createAndJoinRoom(SecurityUtil.getCurrentUserId(), createRoomDto); } @PostMapping("/join/{roomId}") @Operation(summary = "회의방 참여", description = "회의방에 참여합니다.") + @ApiErrorExceptionsExample(CreateRoomExceptionDocs.class) public JoinResponseDto joinRoom(@PathVariable Long roomId) { return meetRoomService.joinRoom(SecurityUtil.getCurrentUserId(), roomId); } @@ -42,12 +48,14 @@ public JoinResponseDto joinRoom(@PathVariable Long roomId) { @Operation(summary = "회의방 목록 요청", description = "현재 워크스페이스에 있는 회의방 목록을 요청합니다.\n" + "이것에 대한 응답은 `/topic/{workSpaceId}/meetingRoomList`로 이루어집니다.\n" ) + @ApiErrorExceptionsExample(WorkspaceGetExceptionDocs.class) public MeetingRoomListDto requestRoomList() { return meetRoomService.sendRoomList(SecurityUtil.getCurrentUserId()); } @PostMapping("/leave") @Operation(summary = "회의방 나가기", description = "현재 참여중인 회의방을 나갑니다.") + @ApiErrorExceptionsExample(LeaveRoomExceptionDocs.class) public void leaveRoom() { meetRoomService.leaveRoom(SecurityUtil.getCurrentUserId()); } diff --git a/src/main/java/capstone/relation/meeting/docs/CreateRoomExceptionDocs.java b/src/main/java/capstone/relation/meeting/docs/CreateRoomExceptionDocs.java new file mode 100644 index 0000000..b25453b --- /dev/null +++ b/src/main/java/capstone/relation/meeting/docs/CreateRoomExceptionDocs.java @@ -0,0 +1,26 @@ +package capstone.relation.meeting.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.meeting.exception.MeetingErrorCode; +import capstone.relation.meeting.exception.MeetingException; + +//TODO: Auth 관련된 녀석들이 공통 되는 부분이 많으니까 그거에 맞게 수정해야할 듯 +@ExceptionDoc +public class CreateRoomExceptionDocs implements SwaggerExampleExceptions { + + @ExplainError("회의실을 생성하거나 참여하고자 할 때 이미 회의실에 참여되어있는 경우 발생합니다.") + public GlobalCodeException 회의실_참여_실패 = new MeetingException(MeetingErrorCode.MEETING_ALREADY_JOINED); + @ExplainError("회의실 이름이 없는 경우 발생합니다.") + public GlobalCodeException 회의실_이름_없음 = new MeetingException(MeetingErrorCode.MEETING_NAME_NOT_EXIST); + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} diff --git a/src/main/java/capstone/relation/meeting/docs/LeaveRoomExceptionDocs.java b/src/main/java/capstone/relation/meeting/docs/LeaveRoomExceptionDocs.java new file mode 100644 index 0000000..48a7199 --- /dev/null +++ b/src/main/java/capstone/relation/meeting/docs/LeaveRoomExceptionDocs.java @@ -0,0 +1,23 @@ +package capstone.relation.meeting.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.meeting.exception.MeetingErrorCode; +import capstone.relation.meeting.exception.MeetingException; + +@ExceptionDoc +public class LeaveRoomExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("회의실을 나가거나 할 때 이미 회의실에 참여되어있지 않은 경우 발생합니다.") + public GlobalCodeException 회의실_나가기_실패 = new MeetingException(MeetingErrorCode.USER_NOT_MEETING_MEMBER); + + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} diff --git a/src/main/java/capstone/relation/meeting/exception/MeetingErrorCode.java b/src/main/java/capstone/relation/meeting/exception/MeetingErrorCode.java new file mode 100644 index 0000000..1e68a1d --- /dev/null +++ b/src/main/java/capstone/relation/meeting/exception/MeetingErrorCode.java @@ -0,0 +1,47 @@ +package capstone.relation.meeting.exception; + +import static capstone.relation.global.consts.JoEunStatic.*; + +import java.lang.reflect.Field; +import java.util.Objects; + +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.dto.ErrorReason; +import capstone.relation.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MeetingErrorCode implements BaseErrorCode { + @ExplainError("해당 회의가 존재하지 않는 경우에 발생합니다.") + INVALID_MEETING(NOT_FOUND, "MEETING_404_1", "해당 회의가 존재하지 않습니다."), + @ExplainError("해당 회의에 가입하지 않은 사용자 입니다.") + USER_NOT_MEETING_MEMBER(403, "MEETING_403_1", "해당 회의에 가입하지 않은 사용자 입니다."), + @ExplainError("회의 참여에 인원 초과로 실패한 경우 발생합니다.") + MEETING_JOIN_FAILED(409, "MEETING_409_1", "회의 참여에 실패했습니다.(인원 초과)"), + @ExplainError("회의에 이미 참여해 있는 경우 발생합니다.") + MEETING_ALREADY_JOINED(409, "MEETING_409_2", "이미 회의에 참여해 있습니다."), + @ExplainError("회의 생성에 실패한 경우에 발생합니다.") + MEETING_CREATE_FAILED(500, "MEETING_500_1", "회의 생성에 실패했습니다."), + @ExplainError("회의 나가기에 실패한 경우에 발생합니다.") + MEETING_LEAVE_FAILED(500, "MEETING_500_2", "회의 나가기에 실패했습니다."), + @ExplainError("회의실 이름이 없는 경우 발생합니다.") + MEETING_NAME_NOT_EXIST(400, "MEETING_400_1", "회의실 이름이 없습니다."); + + private final Integer status; + private final String code; + private final String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.builder().reason(reason).code(code).status(status).build(); + } + + @Override + public String getExplainError() throws NoSuchFieldException { + Field field = this.getClass().getField(this.name()); + ExplainError annotation = field.getAnnotation(ExplainError.class); + return Objects.nonNull(annotation) ? annotation.value() : this.getReason(); + } +} diff --git a/src/main/java/capstone/relation/meeting/exception/MeetingException.java b/src/main/java/capstone/relation/meeting/exception/MeetingException.java new file mode 100644 index 0000000..5517b0c --- /dev/null +++ b/src/main/java/capstone/relation/meeting/exception/MeetingException.java @@ -0,0 +1,9 @@ +package capstone.relation.meeting.exception; + +import capstone.relation.global.exception.GlobalCodeException; + +public class MeetingException extends GlobalCodeException { + public MeetingException(MeetingErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/capstone/relation/meeting/service/MeetRoomService.java b/src/main/java/capstone/relation/meeting/service/MeetRoomService.java index 58f7921..2eb896b 100644 --- a/src/main/java/capstone/relation/meeting/service/MeetRoomService.java +++ b/src/main/java/capstone/relation/meeting/service/MeetRoomService.java @@ -3,17 +3,17 @@ import java.util.List; import java.util.Set; -import org.springframework.http.HttpStatus; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; import capstone.relation.meeting.domain.MeetRoom; import capstone.relation.meeting.dto.request.CreateRoomDto; import capstone.relation.meeting.dto.response.JoinResponseDto; import capstone.relation.meeting.dto.response.MeetingRoomDto; import capstone.relation.meeting.dto.response.MeetingRoomListDto; +import capstone.relation.meeting.exception.MeetingErrorCode; +import capstone.relation.meeting.exception.MeetingException; import capstone.relation.meeting.repository.MeetRoomRepository; import capstone.relation.meeting.repository.RedisRepository; import capstone.relation.user.UserService; @@ -39,7 +39,7 @@ public class MeetRoomService { public JoinResponseDto createAndJoinRoom(Long userId, CreateRoomDto createRoomDto) { String roomName = createRoomDto.getRoomName(); if (roomName == null || roomName.isEmpty()) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "회의실 이름을 입력해주세요."); + throw new MeetingException(MeetingErrorCode.MEETING_NAME_NOT_EXIST); String workSpaceId = userService.getUserWorkSpaceId(userId); // 미팅룸을 생성, 참여, 미팅룸 목록을 전송합니다. @@ -57,7 +57,7 @@ public JoinResponseDto createAndJoinRoom(Long userId, CreateRoomDto createRoomDt */ private Long createRoom(Long userId, String roomName) { if (redisRepository.isUserInRoom(userId)) - throw new IllegalArgumentException("User is already in the room: " + userId); + throw new MeetingException(MeetingErrorCode.MEETING_ALREADY_JOINED); MeetRoom meetRoom = MeetRoom.builder() .roomName(roomName) .deleted(false) @@ -74,7 +74,7 @@ private Long createRoom(Long userId, String roomName) { */ public RoomInfoDto getRoomInfo(String workspaceId, Long userId) { if (!redisRepository.isUserInRoom(userId)) - throw new IllegalArgumentException("User is not in any room: " + userId); + throw new MeetingException(MeetingErrorCode.USER_NOT_MEETING_MEMBER); Long roomId = Long.parseLong(redisRepository.getUserRoomId(userId)); Set userIds = redisRepository.getRoomMemberIds(workspaceId, roomId); @@ -89,7 +89,7 @@ public RoomInfoDto getRoomInfo(String workspaceId, Long userId) { */ private String getRoomName(Long roomId) { return meetRoomRepository.findById(roomId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid room ID : " + roomId)) + .orElseThrow(() -> new MeetingException(MeetingErrorCode.INVALID_MEETING)) .getRoomName(); } @@ -103,7 +103,7 @@ public JoinResponseDto joinRoom(Long userId, Long roomId) { String workspaceId = userService.getUserWorkSpaceId(userId); String meetRoomId = redisRepository.getUserRoomId(userId); if (meetRoomId != null) - throw new IllegalArgumentException("User is already in the room: " + userId); + throw new MeetingException(MeetingErrorCode.MEETING_ALREADY_JOINED); JoinResponseDto joinResponseDto = joinWorkspaceRoom(workspaceId, userId, roomId); sendRoomList(workspaceId); @@ -120,7 +120,7 @@ public JoinResponseDto joinRoom(Long userId, Long roomId) { */ private JoinResponseDto joinWorkspaceRoom(String workSpaceId, Long userId, Long roomId) { MeetRoom meetRoom = meetRoomRepository.findById(roomId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid room ID")); + .orElseThrow(() -> new MeetingException(MeetingErrorCode.INVALID_MEETING)); redisRepository.addUserToRoom(workSpaceId, roomId, userId.toString()); Set userIds = redisRepository.getRoomMemberIds(workSpaceId, roomId); List userInfoList = userService.getUserInfoList(userIds); diff --git a/src/main/java/capstone/relation/user/UserService.java b/src/main/java/capstone/relation/user/UserService.java index d1f4570..f21a885 100644 --- a/src/main/java/capstone/relation/user/UserService.java +++ b/src/main/java/capstone/relation/user/UserService.java @@ -14,6 +14,8 @@ import capstone.relation.api.auth.jwt.SecurityUser; import capstone.relation.user.domain.User; import capstone.relation.user.dto.UserInfoDto; +import capstone.relation.user.exception.UserErrorCode; +import capstone.relation.user.exception.UserException; import capstone.relation.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -42,7 +44,7 @@ public UserInfoDto getUserInfo() { public UserInfoDto getUserInfo(Long userId) { Optional userOptional = userRepository.findById(userId); if (userOptional.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."); + throw new UserException(UserErrorCode.USER_NOT_FOUND); } UserInfoDto userInfoDto = UserMapper.INSTANCE.toUserInfoDto(userOptional.get()); return userInfoDto; diff --git a/src/main/java/capstone/relation/user/controller/UserController.java b/src/main/java/capstone/relation/user/controller/UserController.java index 3d851e4..7c448f3 100644 --- a/src/main/java/capstone/relation/user/controller/UserController.java +++ b/src/main/java/capstone/relation/user/controller/UserController.java @@ -4,8 +4,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import capstone.relation.global.annotation.ApiErrorExceptionsExample; import capstone.relation.meeting.service.MeetRoomService; import capstone.relation.user.UserService; +import capstone.relation.user.docs.UserInfoExceptionDocs; import capstone.relation.user.domain.User; import capstone.relation.user.dto.RoomInfoDto; import capstone.relation.user.dto.UserInfoDto; @@ -23,17 +25,18 @@ public class UserController { @GetMapping("/info") @Operation(summary = "사용자 정보 조회", description = "현재 로그인한 사용자의 정보를 조회합니다.") + @ApiErrorExceptionsExample(UserInfoExceptionDocs.class) public UserInfoDto getInfo() { return userService.getUserInfo(); } @GetMapping("/room/info") @Operation(summary = "사용자 회의 방 정보 조회", description = "현재 로그인한 사용자의 회의 방 정보를 조회합니다.") + @ApiErrorExceptionsExample(UserInfoExceptionDocs.class) public RoomInfoDto getRoomInfo() { User user = userService.getUserEntity(); Long userId = user.getId(); String workspaceId = user.getWorkSpace().getId(); - System.out.println("룸인포!!!!!!!!"); return meetRoomService.getRoomInfo(workspaceId, userId); } } diff --git a/src/main/java/capstone/relation/user/docs/UserInfoExceptionDocs.java b/src/main/java/capstone/relation/user/docs/UserInfoExceptionDocs.java new file mode 100644 index 0000000..03c0d09 --- /dev/null +++ b/src/main/java/capstone/relation/user/docs/UserInfoExceptionDocs.java @@ -0,0 +1,23 @@ +package capstone.relation.user.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.user.exception.UserErrorCode; +import capstone.relation.user.exception.UserException; + +@ExceptionDoc +public class UserInfoExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("유저 정보를 찾을 수 없는 경우 발생합니다.(유저의 탈퇴 등으로 못찾는 경우 또는 가입이 되어있지 않는 경우)") + public GlobalCodeException 유저_정보_없음 = new UserException(UserErrorCode.USER_NOT_FOUND); + + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} diff --git a/src/main/java/capstone/relation/user/exception/UserErrorCode.java b/src/main/java/capstone/relation/user/exception/UserErrorCode.java new file mode 100644 index 0000000..e691766 --- /dev/null +++ b/src/main/java/capstone/relation/user/exception/UserErrorCode.java @@ -0,0 +1,38 @@ +package capstone.relation.user.exception; + +import static capstone.relation.global.consts.JoEunStatic.*; + +import java.lang.reflect.Field; +import java.util.Objects; + +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.dto.ErrorReason; +import capstone.relation.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserErrorCode implements BaseErrorCode { + INVALID_USER(BAD_REQUEST, "USER_400_1", "유효하지 않은 사용자입니다."), + USER_NOT_FOUND(NOT_FOUND, "USER_404_1", "사용자를 찾을 수 없습니다."), + USER_ALREADY_EXISTS(CONFLICT, "USER_409_1", "이미 존재하는 사용자입니다."), + USER_NOT_VERIFIED(UNAUTHORIZED, "USER_401_1", "사용자 인증이 되지 않았습니다."), + USER_NOT_AUTHORIZED(FORBIDDEN, "USER_403_1", "사용자 권한이 없습니다."); + + private final Integer status; + private final String code; + private final String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.builder().reason(reason).code(code).status(status).build(); + } + + @Override + public String getExplainError() throws NoSuchFieldException { + Field field = this.getClass().getField(this.name()); + ExplainError annotation = field.getAnnotation(ExplainError.class); + return Objects.nonNull(annotation) ? annotation.value() : this.getReason(); + } +} diff --git a/src/main/java/capstone/relation/user/exception/UserException.java b/src/main/java/capstone/relation/user/exception/UserException.java new file mode 100644 index 0000000..47989a8 --- /dev/null +++ b/src/main/java/capstone/relation/user/exception/UserException.java @@ -0,0 +1,9 @@ +package capstone.relation.user.exception; + +import capstone.relation.global.exception.GlobalCodeException; + +public class UserException extends GlobalCodeException { + public UserException(UserErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/capstone/relation/websocket/chat/ChatService.java b/src/main/java/capstone/relation/websocket/chat/ChatService.java index ab2aed0..934127d 100644 --- a/src/main/java/capstone/relation/websocket/chat/ChatService.java +++ b/src/main/java/capstone/relation/websocket/chat/ChatService.java @@ -12,14 +12,16 @@ import capstone.relation.api.auth.exception.AuthErrorCode; import capstone.relation.api.auth.exception.AuthException; import capstone.relation.user.domain.User; +import capstone.relation.user.exception.UserErrorCode; +import capstone.relation.user.exception.UserException; import capstone.relation.user.repository.UserRepository; import capstone.relation.websocket.chat.domain.Chat; import capstone.relation.websocket.chat.dto.response.HistoryResponseDto; import capstone.relation.websocket.chat.dto.response.MessageDto; import capstone.relation.websocket.chat.repository.ChatRepository; import capstone.relation.workspace.WorkSpace; -import capstone.relation.workspace.exception.WorkspaceErrorCode; -import capstone.relation.workspace.exception.WorkspaceException; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; import capstone.relation.workspace.repository.WorkSpaceRepository; import lombok.RequiredArgsConstructor; @@ -39,12 +41,12 @@ public MessageDto sendNewMessage(String workSpaceId, String content, SimpMessage Map sessionAttributes = headerAccessor.getSessionAttributes(); Long userId = (Long)sessionAttributes.get("userId"); if (userId == null) - throw new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); User user = userRepository.findById(userId).orElseThrow(() -> - new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN)); + new AuthException(AuthErrorCode.INVALID_TOKEN)); WorkSpace workSpace = workSpaceRepository.findById(workSpaceId).orElse(null); if (user == null || workSpace == null || user.getWorkSpace() != workSpace) - throw new WorkspaceException(WorkspaceErrorCode.INVALID_ACCESS); + throw new WorkSpaceException(WorkSpaceErrorCode.NO_WORKSPACE); Chat chat = new Chat(user, workSpace, content, LocalDateTime.now()); chatRepository.save(chat); @@ -59,11 +61,11 @@ public MessageDto sendNewMessage(String workSpaceId, String content, SimpMessage */ public HistoryResponseDto getHistory(Long lastMsgId, Long userId) { User user = userRepository.findById(userId).orElseThrow(() -> - new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN)); + new UserException(UserErrorCode.INVALID_USER)); WorkSpace workSpace = user.getWorkSpace(); if (workSpace == null) - throw new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN); + throw new WorkSpaceException(WorkSpaceErrorCode.NO_WORKSPACE); List chats = retrieveChats(workSpace, lastMsgId); HistoryResponseDto historyResponseDto = HistoryResponseDto.builder() diff --git a/src/main/java/capstone/relation/websocket/chat/controller/ChatController.java b/src/main/java/capstone/relation/websocket/chat/controller/ChatController.java index 862f09b..76a21e4 100644 --- a/src/main/java/capstone/relation/websocket/chat/controller/ChatController.java +++ b/src/main/java/capstone/relation/websocket/chat/controller/ChatController.java @@ -17,7 +17,6 @@ import capstone.relation.websocket.chat.dto.publish.MessagePublishDto; import capstone.relation.websocket.chat.dto.response.HistoryResponseDto; import capstone.relation.websocket.chat.dto.response.MessageDto; -import capstone.relation.workspace.exception.WorkspaceException; import lombok.RequiredArgsConstructor; @Controller @@ -39,9 +38,9 @@ public ResponseEntity newMessage(@DestinationVariable String workSpaceId, Mes return ResponseEntity .status(401) .body("Access token is already expired or invalid."); - } catch (WorkspaceException e) { - return ResponseEntity.status(404) - .body("The workspace does not exist or you do not have access to it."); + // } catch (WorkspaceException e) { + // return ResponseEntity.status(404) + // .body("The workspace does not exist or you do not have access to it."); } catch (Exception e) { return ResponseEntity.badRequest().build(); } diff --git a/src/main/java/capstone/relation/websocket/chat/controller/ChatDocumentationController.java b/src/main/java/capstone/relation/websocket/chat/docs/ChatDocumentationController.java similarity index 89% rename from src/main/java/capstone/relation/websocket/chat/controller/ChatDocumentationController.java rename to src/main/java/capstone/relation/websocket/chat/docs/ChatDocumentationController.java index 58cfd4f..a63ef16 100644 --- a/src/main/java/capstone/relation/websocket/chat/controller/ChatDocumentationController.java +++ b/src/main/java/capstone/relation/websocket/chat/docs/ChatDocumentationController.java @@ -1,4 +1,4 @@ -package capstone.relation.websocket.chat.controller; +package capstone.relation.websocket.chat.docs; import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; @@ -9,10 +9,13 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import capstone.relation.global.annotation.ApiErrorExceptionsExample; import capstone.relation.websocket.chat.dto.publish.HistoryPublishDto; import capstone.relation.websocket.chat.dto.publish.MessagePublishDto; import capstone.relation.websocket.chat.dto.response.HistoryResponseDto; import capstone.relation.websocket.chat.dto.response.MessageDto; +import capstone.relation.workspace.docs.WorkspaceGetExceptionDocs; +import capstone.relation.workspace.docs.WorkspaceWithIdExceptionDocs; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -26,6 +29,7 @@ @RequestMapping("/ws-chat") public class ChatDocumentationController { + //TODO: WebSocket API exception 문서화 @GetMapping("/topic/message/{workSpaceId}") @Operation(summary = "새 메시지 구독", description = "워크스페이스 ID를 지정하여 새 메시지를 `/topic/message`로 발송합니다. " + "실제 메시지는 STOMP 프로토콜을 통해 이루어집니다.", @@ -52,6 +56,7 @@ public ResponseEntity subscribeHistory() { @PostMapping("/app/message/{workSpaceId}") @Operation(summary = "메시지 발송", description = "새 메시지를 `/app/message` 경로로 발송합니다.") + @ApiErrorExceptionsExample(WorkspaceWithIdExceptionDocs.class) public ResponseEntity sendMessage(@PathVariable String workSpaceId, MessagePublishDto message) { throw new UnsupportedOperationException("This endpoint is not implemented."); } @@ -64,6 +69,7 @@ public ResponseEntity sendMessage(@PathVariable String workSpaceId, Messag @ApiResponse(responseCode = "400", description = "Bad Request") } ) + @ApiErrorExceptionsExample(WorkspaceGetExceptionDocs.class) public ResponseEntity requestHistory(@RequestBody HistoryPublishDto historyPublishDto) { throw new UnsupportedOperationException("This endpoint is not implemented."); } diff --git a/src/main/java/capstone/relation/websocket/chat/stomp/StompPreHandler.java b/src/main/java/capstone/relation/websocket/chat/stomp/StompPreHandler.java index e0f11e7..871d04f 100644 --- a/src/main/java/capstone/relation/websocket/chat/stomp/StompPreHandler.java +++ b/src/main/java/capstone/relation/websocket/chat/stomp/StompPreHandler.java @@ -16,6 +16,8 @@ import capstone.relation.user.repository.UserRepository; import capstone.relation.websocket.SocketRegistry; import capstone.relation.workspace.WorkSpace; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; import lombok.extern.slf4j.Slf4j; @Configuration @@ -41,11 +43,11 @@ public Message preSend(Message message, MessageChannel channel) { if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { String token = accessor.getFirstNativeHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) - throw new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN); + throw new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); token = token.substring(7); // Remove "Bearer " prefix if (!tokenProvider.validateToken(token)) - throw new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); Long userId = tokenProvider.getUserId(token); Long expiryTime = tokenProvider.getExpiryFromToken(token); //나중에 만료시간 설정해서 스캐줄러에 넣으려고 @@ -57,7 +59,7 @@ public Message preSend(Message message, MessageChannel channel) { if (workSpace != null) { accessor.getSessionAttributes().put("workSpaceId", workSpace.getId()); } else { - throw new AuthException(AuthErrorCode.INVALID_WORKSPACE_STATE_USER); + throw new WorkSpaceException(WorkSpaceErrorCode.NO_WORKSPACE); } }); scheduleSessionExpiry(accessor, expiryTime); diff --git a/src/main/java/capstone/relation/websocket/signaling/SignalingDocumentController.java b/src/main/java/capstone/relation/websocket/signaling/docs/SignalingDocumentController.java similarity index 95% rename from src/main/java/capstone/relation/websocket/signaling/SignalingDocumentController.java rename to src/main/java/capstone/relation/websocket/signaling/docs/SignalingDocumentController.java index b929866..cb7c880 100644 --- a/src/main/java/capstone/relation/websocket/signaling/SignalingDocumentController.java +++ b/src/main/java/capstone/relation/websocket/signaling/docs/SignalingDocumentController.java @@ -1,4 +1,4 @@ -package capstone.relation.websocket.signaling; +package capstone.relation.websocket.signaling.docs; import java.util.List; @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import capstone.relation.global.annotation.ApiErrorExceptionsExample; import capstone.relation.meeting.dto.response.MeetingRoomListDto; import capstone.relation.user.dto.UserInfoDto; import capstone.relation.websocket.signaling.dto.IceDto; @@ -46,6 +47,7 @@ public ResponseEntity subScribeOffer(@PathVariable String roomId @Operation(summary = "상대방에게 offer를 보냅니다.", description = "상대방에게 offer를 보냅니다. 이건 Room에 Join하면 보냅니다." + "실제 메시지는 STOMP 프로토콜을 통해 이루어집니다." ) + @ApiErrorExceptionsExample(SignalingExceptionDocs.class) public ResponseEntity sendOffer(@PathVariable String roomId, SdpMessageDto sdpDto) { throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "This endpoint is not implemented."); } @@ -67,6 +69,7 @@ public ResponseEntity subscribeAnswer(@PathVariable String roomI + "userId 는 상대방 id입니다." + "실제 메시지는 STOMP 프로토콜을 통해 이루어집니다." ) + @ApiErrorExceptionsExample(SignalingExceptionDocs.class) public ResponseEntity sendAnswer(@PathVariable String roomId, SdpMessageDto sdpDto) { throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "This endpoint is not implemented."); } @@ -87,6 +90,7 @@ public ResponseEntity subscribeIce(@PathVariable String roomId) { @Operation(summary = "상대방에게 ice candidate를 보냅니다.", description = "상대방에게 ice candidate를 보냅니다." + "실제 메시지는 STOMP 프로토콜을 통해 이루어집니다." ) + @ApiErrorExceptionsExample(SignalingExceptionDocs.class) public ResponseEntity sendIce(@PathVariable String roomId, IceDto iceDto) { throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "This endpoint is not implemented."); } diff --git a/src/main/java/capstone/relation/websocket/signaling/docs/SignalingExceptionDocs.java b/src/main/java/capstone/relation/websocket/signaling/docs/SignalingExceptionDocs.java new file mode 100644 index 0000000..1b23471 --- /dev/null +++ b/src/main/java/capstone/relation/websocket/signaling/docs/SignalingExceptionDocs.java @@ -0,0 +1,31 @@ +package capstone.relation.websocket.signaling.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.meeting.exception.MeetingErrorCode; +import capstone.relation.meeting.exception.MeetingException; +import capstone.relation.user.exception.UserErrorCode; +import capstone.relation.user.exception.UserException; + +@ExceptionDoc +public class SignalingExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("유저 정보를 찾을 수 없는 경우 발생합니다.(유저의 탈퇴 등으로 못찾는 경우 또는 가입이 되어있지 않는 경우)") + public GlobalCodeException 유저_정보_없음 = new UserException(UserErrorCode.USER_NOT_FOUND); + @ExplainError("해당 하는 회의가 없습니다.") + public GlobalCodeException 회의_없음 = new MeetingException(MeetingErrorCode.INVALID_MEETING); + @ExplainError("해당 회의에 가입하지 않은 사용자 입니다.") + public GlobalCodeException 회의_가입_안됨 = new MeetingException(MeetingErrorCode.USER_NOT_MEETING_MEMBER); + @ExplainError("SDP 정보에서 유저 ID가 잘못됐습니다.") + public GlobalCodeException SDP_유저_ID_잘못됨 = new UserException(UserErrorCode.INVALID_USER); + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} + diff --git a/src/main/java/capstone/relation/workspace/controller/WorkSpaceController.java b/src/main/java/capstone/relation/workspace/controller/WorkSpaceController.java index 426dc13..3c15ba6 100644 --- a/src/main/java/capstone/relation/workspace/controller/WorkSpaceController.java +++ b/src/main/java/capstone/relation/workspace/controller/WorkSpaceController.java @@ -9,8 +9,13 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import capstone.relation.global.annotation.ApiErrorExceptionsExample; import capstone.relation.user.UserService; import capstone.relation.user.dto.UserInfoDto; +import capstone.relation.workspace.docs.WorkspaceCreateExceptionDocs; +import capstone.relation.workspace.docs.WorkspaceGetExceptionDocs; +import capstone.relation.workspace.docs.WorkspaceInviteExceptionDocs; +import capstone.relation.workspace.docs.WorkspaceJoinExceptionDocs; import capstone.relation.workspace.dto.request.CreateSpaceRequest; import capstone.relation.workspace.dto.response.InviteCodeResponse; import capstone.relation.workspace.dto.response.SchoolsResponse; @@ -36,19 +41,15 @@ public class WorkSpaceController { private final UserService userService; @GetMapping("/member") - @Operation(summary = "워크스페이스 멤버 조회", responses = { - @ApiResponse(responseCode = "200", description = "워크스페이스 멤버 조회 성공", - content = @Content(schema = @Schema(implementation = UserInfoDto.class))), - }) + @Operation(summary = "워크스페이스 멤버 조회") + @ApiErrorExceptionsExample(WorkspaceGetExceptionDocs.class) public List getMember() { return workspaceService.getMemebers(); } @GetMapping("/info") - @Operation(summary = "워크스페이스 정보 조회", responses = { - @ApiResponse(responseCode = "200", description = "워크스페이스 정보 조회 성공", - content = @Content(schema = @Schema(implementation = WorkspaceInfo.class))), - }) + @Operation(summary = "워크스페이스 정보 조회") + @ApiErrorExceptionsExample(WorkspaceGetExceptionDocs.class) public WorkspaceInfo getInfo() { return workspaceService.getWorkspaceInfo(); } @@ -58,6 +59,7 @@ public WorkspaceInfo getInfo() { @ApiResponse(responseCode = "200", description = "워크스페이스 가입 성공", content = @Content(schema = @Schema(implementation = WorkspaceInfo.class))), }) + @ApiErrorExceptionsExample(WorkspaceJoinExceptionDocs.class) public WorkspaceInfo join() { return workspaceService.joinSpace(); } @@ -66,6 +68,7 @@ public WorkspaceInfo join() { @Operation(summary = "워크스페이스 탈퇴", responses = { @ApiResponse(responseCode = "200", description = "워크스페이스 탈퇴 성공"), }) + @ApiErrorExceptionsExample(WorkspaceGetExceptionDocs.class) public void leave() { userService.leaveWorkspace(); } @@ -75,6 +78,7 @@ public void leave() { @ApiResponse(responseCode = "200", description = "워크스페이스 초대 알림 성공", content = @Content(schema = @Schema(implementation = WorkspaceInfo.class))), }) + @ApiErrorExceptionsExample(WorkspaceInviteExceptionDocs.class) public WorkspaceInfo invited(@RequestParam String inviteCode) { return workspaceService.inviteSpace(inviteCode); } @@ -84,6 +88,7 @@ public WorkspaceInfo invited(@RequestParam String inviteCode) { @ApiResponse(responseCode = "200", description = "워크스페이스 초대 코드 조회 성공", content = @Content(schema = @Schema(implementation = InviteCodeResponse.class))), }) + @ApiErrorExceptionsExample(WorkspaceGetExceptionDocs.class) public InviteCodeResponse invite() { return workspaceService.getInviteCode(); } @@ -104,6 +109,7 @@ public SchoolsResponse getSchool(@RequestParam String name) { @Operation(summary = "워크스페이스 생성", responses = { @ApiResponse(responseCode = "200", description = "워크스페이스 생성 성공") }) + @ApiErrorExceptionsExample(WorkspaceCreateExceptionDocs.class) public WorkspaceInfo create(@Valid @RequestBody CreateSpaceRequest createSpaceRequest) { return workspaceService.createNewSpace(createSpaceRequest); } diff --git a/src/main/java/capstone/relation/workspace/docs/WorkspaceCreateExceptionDocs.java b/src/main/java/capstone/relation/workspace/docs/WorkspaceCreateExceptionDocs.java new file mode 100644 index 0000000..eeb60d2 --- /dev/null +++ b/src/main/java/capstone/relation/workspace/docs/WorkspaceCreateExceptionDocs.java @@ -0,0 +1,24 @@ +package capstone.relation.workspace.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; + +@ExceptionDoc +public class WorkspaceCreateExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("이미 Workspace 에 가입해 있는 경우 발생합니다.") + public GlobalCodeException 워크스페이스_이미_존재 = new WorkSpaceException(WorkSpaceErrorCode.ALREADY_WORKSPACE_MEMBER); + @ExplainError("학교 이름이 올바르지 않은 경우 발생합니다.") + public GlobalCodeException 학교_이름_올바르지_않음 = new WorkSpaceException(WorkSpaceErrorCode.INVALID_SCHOOL); + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} diff --git a/src/main/java/capstone/relation/workspace/docs/WorkspaceGetExceptionDocs.java b/src/main/java/capstone/relation/workspace/docs/WorkspaceGetExceptionDocs.java new file mode 100644 index 0000000..fc3bedf --- /dev/null +++ b/src/main/java/capstone/relation/workspace/docs/WorkspaceGetExceptionDocs.java @@ -0,0 +1,22 @@ +package capstone.relation.workspace.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; + +@ExceptionDoc +public class WorkspaceGetExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("유저가 Workspace 에 참여해 있지 않은 경우 발생합니다.") + public GlobalCodeException 워크스페이스_없음 = new WorkSpaceException(WorkSpaceErrorCode.NO_WORKSPACE); + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} diff --git a/src/main/java/capstone/relation/workspace/docs/WorkspaceInviteExceptionDocs.java b/src/main/java/capstone/relation/workspace/docs/WorkspaceInviteExceptionDocs.java new file mode 100644 index 0000000..390fc6a --- /dev/null +++ b/src/main/java/capstone/relation/workspace/docs/WorkspaceInviteExceptionDocs.java @@ -0,0 +1,26 @@ +package capstone.relation.workspace.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; + +@ExceptionDoc +public class WorkspaceInviteExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("유저가 이미 Workspace 에 참여해 있는 경우 발생합니다.") + public GlobalCodeException 워크스페이스_이미_참여 = new WorkSpaceException(WorkSpaceErrorCode.ALREADY_WORKSPACE_MEMBER); + @ExplainError("초대 코드가 만료되었을 때 발생합니다.") + public GlobalCodeException 초대_만료 = new WorkSpaceException(WorkSpaceErrorCode.EXPIRED_INVITE_CODE); + @ExplainError("초대 코드가 유효하지 않을 때 발생합니다.") + public GlobalCodeException 초대_유효하지_않음 = new WorkSpaceException(WorkSpaceErrorCode.INVALID_INVITE_CODE); + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} diff --git a/src/main/java/capstone/relation/workspace/docs/WorkspaceJoinExceptionDocs.java b/src/main/java/capstone/relation/workspace/docs/WorkspaceJoinExceptionDocs.java new file mode 100644 index 0000000..964a6b5 --- /dev/null +++ b/src/main/java/capstone/relation/workspace/docs/WorkspaceJoinExceptionDocs.java @@ -0,0 +1,27 @@ +package capstone.relation.workspace.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; + +@ExceptionDoc +public class WorkspaceJoinExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("유저가 이미 Workspace 에 참여해 있는 경우 발생합니다.") + public GlobalCodeException 워크스페이스_이미_참여 = new WorkSpaceException(WorkSpaceErrorCode.ALREADY_WORKSPACE_MEMBER); + @ExplainError("워크스페이스에 초대된 적이 없는 유저일 때 발생합니다.") + public GlobalCodeException 초대_없음 = new WorkSpaceException(WorkSpaceErrorCode.NOT_INVITED_USER); + @ExplainError("초대된 Workspace 가 존재하지 않는 경우 발생합니다.(삭제되었거나 만료된 경우)") + public GlobalCodeException 워크스페이스_없음 = new WorkSpaceException(WorkSpaceErrorCode.INVALID_WORKSPACE); + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} + diff --git a/src/main/java/capstone/relation/workspace/docs/WorkspaceWithIdExceptionDocs.java b/src/main/java/capstone/relation/workspace/docs/WorkspaceWithIdExceptionDocs.java new file mode 100644 index 0000000..d8be761 --- /dev/null +++ b/src/main/java/capstone/relation/workspace/docs/WorkspaceWithIdExceptionDocs.java @@ -0,0 +1,28 @@ +package capstone.relation.workspace.docs; + +import capstone.relation.api.auth.exception.AuthErrorCode; +import capstone.relation.api.auth.exception.AuthException; +import capstone.relation.global.annotation.ExceptionDoc; +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.exception.GlobalCodeException; +import capstone.relation.global.interfaces.SwaggerExampleExceptions; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; + +@ExceptionDoc +public class WorkspaceWithIdExceptionDocs implements SwaggerExampleExceptions { + @ExplainError("유저가 Workspace 에 참여해 있지 않은 경우 발생합니다.") + public GlobalCodeException 워크스페이스_없음 = new WorkSpaceException(WorkSpaceErrorCode.NO_WORKSPACE); + + @ExplainError("해당 Workspace 에 참여해 있지 않은 경우 발생합니다.") + public GlobalCodeException 워크스페이스_멤버아님 = new WorkSpaceException(WorkSpaceErrorCode.USER_NOT_WORKSPACE_MEMBER); + + @ExplainError("해당 Workspace 가 존재하지 않는 경우 발생합니다.") + public GlobalCodeException 워크스페이스_없음_2 = new WorkSpaceException(WorkSpaceErrorCode.INVALID_WORKSPACE); + @ExplainError("엑세스 토큰이 만료된 경우 발생합니다.") + public GlobalCodeException 토큰_만료 = new AuthException(AuthErrorCode.TOKEN_EXPIRED); + @ExplainError("엑세스 토큰이 유효하지 않은 경우 발생합니다.") + public GlobalCodeException 토큰_유효하지_않음 = new AuthException(AuthErrorCode.INVALID_TOKEN); + @ExplainError("엑세스 토큰이 없는 경우 발생합니다.") + public GlobalCodeException 토큰_없음 = new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST); +} diff --git a/src/main/java/capstone/relation/workspace/exception/WorkSpaceErrorCode.java b/src/main/java/capstone/relation/workspace/exception/WorkSpaceErrorCode.java new file mode 100644 index 0000000..92d76c1 --- /dev/null +++ b/src/main/java/capstone/relation/workspace/exception/WorkSpaceErrorCode.java @@ -0,0 +1,51 @@ +package capstone.relation.workspace.exception; + +import static capstone.relation.global.consts.JoEunStatic.*; + +import java.lang.reflect.Field; +import java.util.Objects; + +import capstone.relation.global.annotation.ExplainError; +import capstone.relation.global.dto.ErrorReason; +import capstone.relation.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum WorkSpaceErrorCode implements BaseErrorCode { + @ExplainError("유저가 가입한 워크스페이스가 없을 때 발생합니다.") + NO_WORKSPACE(404, "WORKSPACE_404_1", "가입한 워크스페이스가 없습니다."), + @ExplainError("해당 워크스페이스가 가입은 되어 있으나 접근 권한이 없는 경우에 발생합니다.") + INVALID_ACCESS(FORBIDDEN, "WORKSPACE_403_1", "해당 워크스페이스에 접근 권한이 없습니다."), + @ExplainError("해당 워크스페이스가 존재하지 않는 경우에 발생합니다. 초대 이후에 삭제된 경우 등") + INVALID_WORKSPACE(NOT_FOUND, "WORKSPACE_404_2", "해당 워크스페이스가 존재하지 않습니다."), + @ExplainError("해당 워크스페이스에 가입하지 않은 사용자 입니다.") + USER_NOT_WORKSPACE_MEMBER(403, "WORKSPACE_403_1", "해당 워크스페이스에 가입하지 않은 사용자 입니다."), + + @ExplainError("이미 워크스페이스에 가입된 유저입니다.") + ALREADY_WORKSPACE_MEMBER(409, "WORKSPACE_409_1", "이미 워크스페이스에 가입된 유저입니다."), + @ExplainError("워크스페이스에 초대된 적이 없는 유저입니다.") + NOT_INVITED_USER(403, "WORKSPACE_403_2", "워크스페이스에 초대된 적이 없는 유저입니다."), + @ExplainError("유효하지 않은 초대 코드입니다.") + INVALID_INVITE_CODE(BAD_REQUEST, "WORKSPACE_400_1", "유효하지 않은 초대 코드입니다."), + @ExplainError("만료된 초대 코드입니다.") + EXPIRED_INVITE_CODE(BAD_REQUEST, "USER_400_2", "만료된 초대 코드입니다."), + @ExplainError("실제로 존재하지 않는 학교입니다.") + INVALID_SCHOOL(404, "SCHOOL_404_1", "실제로 존재하지 않는 학교입니다."); + private Integer status; + private String code; + private String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.builder().reason(reason).code(code).status(status).build(); + } + + @Override + public String getExplainError() throws NoSuchFieldException { + Field field = this.getClass().getField(this.name()); + ExplainError annotation = field.getAnnotation(ExplainError.class); + return Objects.nonNull(annotation) ? annotation.value() : this.getReason(); + } +} diff --git a/src/main/java/capstone/relation/workspace/exception/WorkspaceErrorCode.java b/src/main/java/capstone/relation/workspace/exception/WorkspaceErrorCode.java deleted file mode 100644 index 07275b3..0000000 --- a/src/main/java/capstone/relation/workspace/exception/WorkspaceErrorCode.java +++ /dev/null @@ -1,20 +0,0 @@ -package capstone.relation.workspace.exception; - -import org.springframework.http.HttpStatus; - -import lombok.Getter; - -@Getter -public enum WorkspaceErrorCode { - INVALID_ACCESS(HttpStatus.BAD_REQUEST, "해당 워크스페이스에 접근 권한이 없습니다."), - INVALID_WORKSPACE(HttpStatus.BAD_REQUEST, "해당 워크스페이스가 존재하지 않습니다."), - INVALID_WORKSPACE_JOIN(HttpStatus.BAD_REQUEST, "해당 워크스페이스에 가입하지 않았습니다."); - - private final HttpStatus httpStatus; - private final String message; - - WorkspaceErrorCode(HttpStatus httpStatus, String message) { - this.httpStatus = httpStatus; - this.message = message; - } -} diff --git a/src/main/java/capstone/relation/workspace/exception/WorkspaceException.java b/src/main/java/capstone/relation/workspace/exception/WorkspaceException.java index 96810ef..fd09f7d 100644 --- a/src/main/java/capstone/relation/workspace/exception/WorkspaceException.java +++ b/src/main/java/capstone/relation/workspace/exception/WorkspaceException.java @@ -1,19 +1,9 @@ package capstone.relation.workspace.exception; -import lombok.Getter; +import capstone.relation.global.exception.GlobalCodeException; -@Getter -public class WorkspaceException extends RuntimeException { - private final WorkspaceErrorCode errorCode; - - public WorkspaceException(WorkspaceErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - } - - public WorkspaceException(WorkspaceErrorCode errorCode, String message) { - super(errorCode.getMessage() + " : " + message); - this.errorCode = errorCode; +public class WorkSpaceException extends GlobalCodeException { + public WorkSpaceException(WorkSpaceErrorCode errorCode) { + super(errorCode); } - } diff --git a/src/main/java/capstone/relation/workspace/service/InvitationService.java b/src/main/java/capstone/relation/workspace/service/InvitationService.java index b255372..5bab8c4 100644 --- a/src/main/java/capstone/relation/workspace/service/InvitationService.java +++ b/src/main/java/capstone/relation/workspace/service/InvitationService.java @@ -1,8 +1,6 @@ package capstone.relation.workspace.service; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; import capstone.relation.api.auth.jwt.TokenProvider; import capstone.relation.api.auth.jwt.response.WorkspaceStateType; @@ -10,6 +8,8 @@ import capstone.relation.workspace.WorkSpace; import capstone.relation.workspace.WorkSpaceMapper; import capstone.relation.workspace.dto.response.WorkspaceInfo; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; import capstone.relation.workspace.repository.WorkSpaceRepository; import lombok.RequiredArgsConstructor; @@ -23,7 +23,7 @@ public class InvitationService { public WorkspaceInfo inviteWorkspace(String inviteCode) { WorkSpace workSpace = getWorkSpace(inviteCode); if (workSpace.getUser().contains(userService.getUserEntity())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 가입된 워크스페이스입니다."); + throw new WorkSpaceException(WorkSpaceErrorCode.ALREADY_WORKSPACE_MEMBER); } userService.setInvitedWorkspaceId(workSpace.getId()); WorkspaceInfo dto = WorkSpaceMapper.INSTANCE.toDto(workSpace); @@ -34,10 +34,10 @@ public WorkspaceInfo inviteWorkspace(String inviteCode) { public WorkspaceInfo joinWorkspace() { String invitedWorkspaceId = userService.getUserEntity().getInvitedWorkspaceId(); if (invitedWorkspaceId == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이전에 초대를 하지 않은 유저가 접근합니다."); + throw new WorkSpaceException(WorkSpaceErrorCode.NOT_INVITED_USER); } WorkSpace workSpace = workSpaceRepository.findById(invitedWorkspaceId).orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "초대코드에 해당하는 워크스페이스가 없습니다.")); + () -> new WorkSpaceException(WorkSpaceErrorCode.NO_WORKSPACE)); workSpace.addUser(userService.getUserEntity()); workSpaceRepository.save(workSpace); WorkspaceInfo dto = WorkSpaceMapper.INSTANCE.toDto(workSpace); @@ -53,7 +53,7 @@ public WorkSpace getWorkSpace(String inviteCode) { String workSpaceId = tokenProvider.getWorkSpaceIdByInviteCode(inviteCode); WorkSpace workSpace = workSpaceRepository.findById(workSpaceId).orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "초대코드에 해당하는 워크스페이스가 없습니다.")); + () -> new WorkSpaceException(WorkSpaceErrorCode.NO_WORKSPACE)); return workSpace; } diff --git a/src/main/java/capstone/relation/workspace/service/WorkspaceService.java b/src/main/java/capstone/relation/workspace/service/WorkspaceService.java index f1baf88..2ee21f9 100644 --- a/src/main/java/capstone/relation/workspace/service/WorkspaceService.java +++ b/src/main/java/capstone/relation/workspace/service/WorkspaceService.java @@ -4,9 +4,7 @@ import java.util.Optional; import java.util.stream.Collectors; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; import capstone.relation.api.auth.jwt.response.WorkspaceStateType; import capstone.relation.user.UserMapper; @@ -17,6 +15,8 @@ import capstone.relation.workspace.dto.request.CreateSpaceRequest; import capstone.relation.workspace.dto.response.InviteCodeResponse; import capstone.relation.workspace.dto.response.WorkspaceInfo; +import capstone.relation.workspace.exception.WorkSpaceErrorCode; +import capstone.relation.workspace.exception.WorkSpaceException; import capstone.relation.workspace.repository.WorkSpaceRepository; import capstone.relation.workspace.school.domain.School; import capstone.relation.workspace.school.service.SchoolService; @@ -33,13 +33,13 @@ public class WorkspaceService { public WorkspaceInfo createNewSpace(CreateSpaceRequest request) { Optional schoolEntity = schoolService.getSchoolEntity(request.getSchoolName()); if (schoolEntity.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "School name is not valid."); + throw new WorkSpaceException(WorkSpaceErrorCode.INVALID_SCHOOL); } User user = userService.getUserEntity(); WorkSpace workSpace = user.getWorkSpace(); if (workSpace != null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "User already has workspace."); + throw new WorkSpaceException(WorkSpaceErrorCode.ALREADY_WORKSPACE_MEMBER); } School school = schoolEntity.get(); workSpace = new WorkSpace(); @@ -66,9 +66,9 @@ public WorkspaceInfo joinSpace() { public InviteCodeResponse getInviteCode() { User user = userService.getUserEntity(); WorkSpace workSpace = user.getWorkSpace(); - if (workSpace == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "User does not have workspace."); - } + if (workSpace == null) + throw new WorkSpaceException(WorkSpaceErrorCode.NO_WORKSPACE); + return new InviteCodeResponse(invitationService.generateInviteCode(workSpace.getId())); } @@ -108,13 +108,11 @@ public List getMemebers() { WorkSpace workSpace = user.getWorkSpace(); if (workSpace == null) { String invitedWorkspaceId = user.getInvitedWorkspaceId(); - if (invitedWorkspaceId == null || invitedWorkspaceId.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, - "User does not have workspace OR invited workspace."); - } + if (invitedWorkspaceId == null || invitedWorkspaceId.isEmpty()) + throw new WorkSpaceException(WorkSpaceErrorCode.NOT_INVITED_USER); workSpace = workSpaceRepository.findById(invitedWorkspaceId) .orElseThrow( - () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "User does not have invited workspace.")); + () -> new WorkSpaceException(WorkSpaceErrorCode.NOT_INVITED_USER)); } return workSpace.getUser().stream() .map(UserMapper.INSTANCE::toUserInfoDto) diff --git a/src/test/java/capstone/relation/global/exception/ProblemDetailCreatorTest.java b/src/test/java/capstone/relation/global/exception/ProblemDetailCreatorTest.java deleted file mode 100644 index aaebbcf..0000000 --- a/src/test/java/capstone/relation/global/exception/ProblemDetailCreatorTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package capstone.relation.global.exception; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.mock.web.MockHttpServletRequest; - -class ProblemDetailCreatorTest { - @Test - @DisplayName("create 메서드 테스트") - void createTest() { - // Given - Exception exception = new Exception("Test exception"); - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRequestURI("/test/uri"); - - // When - ProblemDetail problemDetail = ProblemDetailCreator.create(exception, request, HttpStatus.BAD_REQUEST); - - // Then - assertEquals(HttpStatus.BAD_REQUEST.value(), problemDetail.getStatus()); - assertEquals("Test exception", problemDetail.getDetail()); - assertEquals("/test/uri", problemDetail.getInstance().toString()); - assertEquals("Exception", problemDetail.getTitle()); - } -} diff --git a/src/test/java/capstone/relation/meeting/service/MeetRoomServiceTest.java b/src/test/java/capstone/relation/meeting/service/MeetRoomServiceTest.java index a81b3f1..13fc796 100644 --- a/src/test/java/capstone/relation/meeting/service/MeetRoomServiceTest.java +++ b/src/test/java/capstone/relation/meeting/service/MeetRoomServiceTest.java @@ -10,15 +10,14 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.springframework.http.HttpStatus; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.web.server.ResponseStatusException; import capstone.relation.meeting.domain.MeetRoom; import capstone.relation.meeting.dto.request.CreateRoomDto; import capstone.relation.meeting.dto.response.JoinResponseDto; import capstone.relation.meeting.dto.response.MeetingRoomListDto; +import capstone.relation.meeting.exception.MeetingException; import capstone.relation.meeting.repository.MeetRoomRepository; import capstone.relation.meeting.repository.RedisRepository; import capstone.relation.security.WithMockCustomUser; @@ -82,12 +81,7 @@ public void createAndJoinRoomWithInvalidRoomName() { // when & then assertThatThrownBy(() -> meetRoomService.createAndJoinRoom(1L, createRoomDto)) - .isInstanceOf(ResponseStatusException.class) - .satisfies(exception -> { - ResponseStatusException ex = (ResponseStatusException)exception; - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(ex.getReason()).isEqualTo("회의실 이름을 입력해주세요."); - }); + .isInstanceOf(MeetingException.class); } @DisplayName("이미 회의실에 참여한 사용자가 다시 참여하려고 할 때 예외가 발생한다.") @@ -103,8 +97,7 @@ public void createAndJoinRoomWithAlreadyJoinedUser() { // when & then assertThatThrownBy(() -> meetRoomService.createAndJoinRoom(1L, createRoomDto)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("User is already in the room: 1"); + .isInstanceOf(MeetingException.class); } @DisplayName("워크스페이스에 참여한 모든 유저에게 회의실 목록을 전송할 수 있다.") diff --git a/src/test/java/capstone/relation/websocket/chat/ChatServiceTest.java b/src/test/java/capstone/relation/websocket/chat/ChatServiceTest.java index 362da70..bcf2d3f 100644 --- a/src/test/java/capstone/relation/websocket/chat/ChatServiceTest.java +++ b/src/test/java/capstone/relation/websocket/chat/ChatServiceTest.java @@ -16,14 +16,15 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import capstone.relation.api.auth.exception.AuthException; import capstone.relation.user.domain.User; +import capstone.relation.user.exception.UserException; import capstone.relation.user.repository.UserRepository; import capstone.relation.websocket.chat.domain.Chat; import capstone.relation.websocket.chat.dto.response.HistoryResponseDto; import capstone.relation.websocket.chat.dto.response.MessageDto; import capstone.relation.websocket.chat.repository.ChatRepository; import capstone.relation.workspace.WorkSpace; +import capstone.relation.workspace.exception.WorkSpaceException; @ExtendWith(MockitoExtension.class) @DisplayName("ChatService 단위 테스트") @@ -81,20 +82,20 @@ void whenUserIdIsValidAndWorkSpaceExists_thenReturnHistory() { } @Test - @DisplayName("사용자 ID가 유효하지 않으면 AuthException을 던진다.") + @DisplayName("사용자 ID가 유효하지 않으면 User Exception 을 던진다.") void whenUserIdIsInvalid_thenThrowAuthException() { // given Long userId = 1L; when(userRepository.findById(userId)).thenReturn(Optional.empty()); // when & then - assertThrows(AuthException.class, () -> chatService.getHistory(null, userId)); + assertThrows(UserException.class, () -> chatService.getHistory(null, userId)); verify(userRepository, times(1)).findById(userId); } @Test - @DisplayName("작업 공간이 없으면 AuthException을 던진다.") + @DisplayName("작업 공간이 없으면 WorkSpace Exception 을 던진다.") void whenUserHasNoWorkSpace_thenThrowAuthException() { // given Long userId = 1L; @@ -102,7 +103,7 @@ void whenUserHasNoWorkSpace_thenThrowAuthException() { mockUserRepository(userId); // when & then - assertThrows(AuthException.class, () -> chatService.getHistory(null, userId)); + assertThrows(WorkSpaceException.class, () -> chatService.getHistory(null, userId)); verify(userRepository, times(1)).findById(userId); } diff --git a/src/test/java/capstone/relation/websocket/chat/integration/ChatServiceIntegrationTest.java b/src/test/java/capstone/relation/websocket/chat/integration/ChatServiceIntegrationTest.java index fdd8e9d..064e046 100644 --- a/src/test/java/capstone/relation/websocket/chat/integration/ChatServiceIntegrationTest.java +++ b/src/test/java/capstone/relation/websocket/chat/integration/ChatServiceIntegrationTest.java @@ -22,7 +22,7 @@ import capstone.relation.websocket.chat.dto.response.MessageDto; import capstone.relation.websocket.chat.repository.ChatRepository; import capstone.relation.workspace.WorkSpace; -import capstone.relation.workspace.exception.WorkspaceException; +import capstone.relation.workspace.exception.WorkSpaceException; import capstone.relation.workspace.repository.WorkSpaceRepository; @SpringBootTest @@ -117,7 +117,7 @@ void testSendNewMessage_InvalidWorkspace() { String content = "Test message"; // Then: WorkspaceException이 발생해야 한다. - assertThrows(WorkspaceException.class, () -> { + assertThrows(WorkSpaceException.class, () -> { chatService.sendNewMessage(invalidWorkSpaceId, content, headerAccessor); }); }