diff --git a/src/main/java/com/ject/studytrip/global/common/constants/SwaggerUrlConstants.java b/src/main/java/com/ject/studytrip/global/common/constants/SwaggerUrlConstants.java new file mode 100644 index 0000000..75fe8c4 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/common/constants/SwaggerUrlConstants.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.global.common.constants; + +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SwaggerUrlConstants { + SWAGGER_RESOURCES_URL("/swagger-resources/**"), + SWAGGER_UI_URL("/swagger-ui/**"), + SWAGGER_API_DOCS_URL("/v3/api-docs/**"), + ; + + private final String value; + + public static String[] getSwaggerUrls() { + return Arrays.stream(SwaggerUrlConstants.values()) + .map(SwaggerUrlConstants::getValue) + .toArray(String[]::new); + } +} diff --git a/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java b/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java new file mode 100644 index 0000000..5e8bec4 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java @@ -0,0 +1,18 @@ +package com.ject.studytrip.global.common.constants; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UrlConstants { + DEV_API_SERVER_URL("https://dev-api-studytrip.duckdns.org"), + LOCAL_API_SERVER_URL("http://localhost:8080"), + + // TODO: 개발, 운영 도메인 URL 추가 작업 + LOCAL_DOMAIN_URL("http://localhost:3000"), + LOCAL_SECURE_DOMAIN_URL("https://localhost:3000"), + ; + + private final String value; +} diff --git a/src/main/java/com/ject/studytrip/global/common/response/StandardResponse.java b/src/main/java/com/ject/studytrip/global/common/response/StandardResponse.java new file mode 100644 index 0000000..2fe86e7 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/common/response/StandardResponse.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.global.common.response; + +import com.ject.studytrip.global.exception.response.ErrorResponse; + +public record StandardResponse(boolean success, int status, Object data) { + + public static StandardResponse success(int status, Object data) { + return new StandardResponse(true, status, data); + } + + public static StandardResponse fail(int status, ErrorResponse errorResponse) { + return new StandardResponse(false, status, errorResponse); + } +} diff --git a/src/main/java/com/ject/studytrip/global/config/QuerydslConfig.java b/src/main/java/com/ject/studytrip/global/config/QuerydslConfig.java new file mode 100644 index 0000000..69d155a --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.ject.studytrip.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class QuerydslConfig { + + private final EntityManager em; + + @Bean + public JPAQueryFactory queryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/src/main/java/com/ject/studytrip/global/config/RedisConfig.java b/src/main/java/com/ject/studytrip/global/config/RedisConfig.java new file mode 100644 index 0000000..c24f709 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/RedisConfig.java @@ -0,0 +1,42 @@ +package com.ject.studytrip.global.config; + +import com.ject.studytrip.global.config.properties.RedisProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@EnableConfigurationProperties(RedisProperties.class) +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisProperties.host(), redisProperties.port()); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + // 단순 Key-Value 직렬화 + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + + // 해시 Key-Value 직렬화 + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/ject/studytrip/global/config/SchedulerConfig.java b/src/main/java/com/ject/studytrip/global/config/SchedulerConfig.java new file mode 100644 index 0000000..6149aed --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/SchedulerConfig.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfig {} diff --git a/src/main/java/com/ject/studytrip/global/config/SwaggerConfig.java b/src/main/java/com/ject/studytrip/global/config/SwaggerConfig.java new file mode 100644 index 0000000..816024b --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/SwaggerConfig.java @@ -0,0 +1,77 @@ +package com.ject.studytrip.global.config; + +import com.ject.studytrip.global.config.properties.SwaggerProperties; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.security.SecurityScheme.*; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Profile("!prod") // 로컬, 개발환경 활성화 +@EnableConfigurationProperties(SwaggerProperties.class) +@Configuration +@RequiredArgsConstructor +public class SwaggerConfig { + + private static final String SERVER_NAME = "StudyTrip"; + private static final String SERVER_DESCRIPTION = "StudyTrip 서버 URL 입니다."; + private static final String API_TITLE = "StudyTrip 서버 API 문서"; + private static final String API_DESCRIPTION = "StudyTrip 서버 API 문서입니다."; + private static final String GITHUB_URL = "https://github.com/JECT-Study/JECT-4-server"; + + private final SwaggerProperties swaggerProperties; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .servers(swaggerServer()) + .addSecurityItem(securityRequirement()) + .components(authComponents()) + .info(swaggerInfo()); + } + + private List swaggerServer() { + Server server = + new Server().url(swaggerProperties.serverUrl()).description(SERVER_DESCRIPTION); + return List.of(server); + } + + private Components authComponents() { + return new Components() + .addSecuritySchemes( + "accessToken", + new SecurityScheme() + .type(Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(In.HEADER) + .name("Authorization")); + } + + private SecurityRequirement securityRequirement() { + SecurityRequirement securityRequirement = new SecurityRequirement(); + securityRequirement.addList("accessToken"); + return securityRequirement; + } + + private Info swaggerInfo() { + License license = new License(); + license.setUrl(GITHUB_URL); + license.setName(SERVER_NAME); + + return new Info() + .version("v" + swaggerProperties.version()) + .title(API_TITLE) + .description(API_DESCRIPTION) + .license(license); + } +} diff --git a/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java new file mode 100644 index 0000000..d6cb160 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java @@ -0,0 +1,88 @@ +package com.ject.studytrip.global.config; + +import com.ject.studytrip.global.common.constants.SwaggerUrlConstants; +import com.ject.studytrip.global.security.CustomAccessDeniedHandler; +import com.ject.studytrip.global.security.CustomAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Slf4j +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class WebSecurityConfig { + + private final CustomAuthenticationEntryPoint authenticationEntryPoint; + private final CustomAccessDeniedHandler accessDeniedHandler; + + private void defaultFilterChain(HttpSecurity http) throws Exception { + http + // csrf 비활성화 + .csrf(AbstractHttpConfigurer::disable) + // http basic 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + // 폼 로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + // 세션 비활성화 + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // cors 설정 + .cors(cors -> cors.configurationSource(corsConfigurationSource())); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + defaultFilterChain(http); + + // 경로 인가 설정 + http.authorizeHttpRequests( + authorize -> + authorize + .requestMatchers(SwaggerUrlConstants.getSwaggerUrls()) + .permitAll() // Swagger 경로 + .requestMatchers("/api/sample/**") + .permitAll() // 샘플 api 경로 + .anyRequest() + .authenticated()); // 그 외 요청은 모두 인증 수행 + + // 예외 핸들링 + http.exceptionHandling( + exception -> + exception + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)); + + return http.build(); + } + + // CORS 설정 + @Bean + public UrlBasedCorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + + // TODO: 환경별 CORS 허용 origin 설정 분기 처리 + // - dev: LOCAL_DOMAIN, LOCAL_SECURE_DOMAIN, DEV_DOMAIN 허용 + // - prod: PROD_DOMAIN 만 허용 + // - Spring Active Profile 기반 분기 필요 + // - 서비스 도메인, 서버 운영 환경 설정 완료 시 작업 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return source; + } +} diff --git a/src/main/java/com/ject/studytrip/global/config/properties/RedisProperties.java b/src/main/java/com/ject/studytrip/global/config/properties/RedisProperties.java new file mode 100644 index 0000000..3ca44f0 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/properties/RedisProperties.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.data.redis") +public record RedisProperties(int port, String host) {} diff --git a/src/main/java/com/ject/studytrip/global/config/properties/SwaggerProperties.java b/src/main/java/com/ject/studytrip/global/config/properties/SwaggerProperties.java new file mode 100644 index 0000000..abffabe --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/properties/SwaggerProperties.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "swagger") +public record SwaggerProperties(String version, String serverUrl) {} diff --git a/src/main/java/com/ject/studytrip/global/exception/CustomException.java b/src/main/java/com/ject/studytrip/global/exception/CustomException.java new file mode 100644 index 0000000..7277726 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/exception/CustomException.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.global.exception; + +import com.ject.studytrip.global.exception.error.CommonErrorCode; +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(CommonErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/ject/studytrip/global/exception/error/AuthErrorCode.java b/src/main/java/com/ject/studytrip/global/exception/error/AuthErrorCode.java new file mode 100644 index 0000000..2aa5b44 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/exception/error/AuthErrorCode.java @@ -0,0 +1,29 @@ +package com.ject.studytrip.global.exception.error; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + UNAUTHENTICATED(HttpStatus.UNAUTHORIZED, "인증되지 않은 요청입니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 부족합니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/global/exception/error/CommonErrorCode.java b/src/main/java/com/ject/studytrip/global/exception/error/CommonErrorCode.java new file mode 100644 index 0000000..3d145ea --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/exception/error/CommonErrorCode.java @@ -0,0 +1,34 @@ +package com.ject.studytrip.global.exception.error; + +import lombok.*; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum CommonErrorCode implements ErrorCode { + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드 입니다."), + METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "요청 본문(JSON)의 값 유효성 검증에 실패했습니다."), + INVALID_JSON_FORMAT( + HttpStatus.BAD_REQUEST, "요청 본문(JSON) 형식이 잘못되어 파싱할 수 없습니다. (필드 타입 불일치, 필수 필드 누락 등)"), + CONSTRAINT_VIOLATION(HttpStatus.BAD_REQUEST, "요청 파라미터 또는 경로 변수 유효성 검증에 실패했습니다."), + METHOD_ARGUMENT_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "요청한 메서드 파마미터의 타입이 일치하지 않습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러. 관리자에게 문의하세요."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/global/exception/error/ErrorCode.java b/src/main/java/com/ject/studytrip/global/exception/error/ErrorCode.java new file mode 100644 index 0000000..3aaf0cc --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/exception/error/ErrorCode.java @@ -0,0 +1,12 @@ +package com.ject.studytrip.global.exception.error; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + String getName(); + + HttpStatus getStatus(); + + String getMessage(); +} diff --git a/src/main/java/com/ject/studytrip/global/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/ject/studytrip/global/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..d115330 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,46 @@ +package com.ject.studytrip.global.exception.handler; + +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.global.exception.error.CommonErrorCode; +import com.ject.studytrip.global.exception.error.ErrorCode; +import com.ject.studytrip.global.exception.response.ErrorResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + /** CustomException 예외 처리 */ + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + log.error("CustomException : {}", e.getMessage(), e); + + final ErrorCode errorCode = e.getErrorCode(); + final ErrorResponse errorResponse = + ErrorResponse.of(errorCode.getName(), errorCode.getMessage()); + final StandardResponse response = + StandardResponse.fail(errorCode.getStatus().value(), errorResponse); + + return ResponseEntity.status(errorCode.getStatus()).body(response); + } + + /** 500번대 에러 처리 */ + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception e) { + log.error("Internal Server Error : {}", e.getMessage(), e); + + final ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; + final ErrorResponse errorResponse = + ErrorResponse.of(errorCode.getName(), errorCode.getMessage()); + final StandardResponse response = + StandardResponse.fail(errorCode.getStatus().value(), errorResponse); + + return ResponseEntity.status(errorCode.getStatus()).body(response); + } +} diff --git a/src/main/java/com/ject/studytrip/global/exception/handler/ValidationExceptionHandler.java b/src/main/java/com/ject/studytrip/global/exception/handler/ValidationExceptionHandler.java new file mode 100644 index 0000000..0c61174 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/exception/handler/ValidationExceptionHandler.java @@ -0,0 +1,160 @@ +package com.ject.studytrip.global.exception.handler; + +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.global.exception.error.CommonErrorCode; +import com.ject.studytrip.global.exception.error.ErrorCode; +import com.ject.studytrip.global.exception.response.ErrorResponse; +import com.ject.studytrip.global.exception.response.FieldErrorResponse; +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +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.WebRequest; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +@Order(Ordered.HIGHEST_PRECEDENCE) +public class ValidationExceptionHandler extends ResponseEntityExceptionHandler { + + /** ResponseEntityExceptionHandler 가 기본으로 처리하는 예외를 공통으로 처리 */ + @Override + protected ResponseEntity handleExceptionInternal( + Exception e, + Object body, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest webRequest) { + ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), e.getMessage()); + + return super.handleExceptionInternal(e, errorResponse, headers, statusCode, webRequest); + } + + /** 요청 본문(Json) 에서 유효성 제약 조건 위반 시 발생(바인딩 실패), 주로 RequestBody, RequestPart 에서 발생 */ + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest webRequest) { + log.error("MethodArgumentNotValidException : {}", e.getMessage(), e); + + List fieldErrors = + e.getBindingResult().getFieldErrors().stream() + .map( + fieldError -> + FieldErrorResponse.of( + fieldError.getField(), + fieldError.getDefaultMessage())) + .toList(); + + final ErrorCode errorCode = CommonErrorCode.METHOD_ARGUMENT_NOT_VALID; + final ErrorResponse errorResponse = + ErrorResponse.of(errorCode.getName(), errorCode.getMessage(), fieldErrors); + final StandardResponse response = StandardResponse.fail(statusCode.value(), errorResponse); + + return ResponseEntity.status(statusCode).body(response); + } + + /** 요청 본문(JSON) 형식이 잘못되어 파싱할 수 없는 경우 발생 (필드 타입 불일치, 필수 필드 누락 시 발생) */ + @Override + protected ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException e, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest request) { + log.error("HttpMessageNotReadableException : {}", e.getMessage(), e); + + final ErrorCode errorCode = CommonErrorCode.INVALID_JSON_FORMAT; + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), errorCode.getMessage()); + final StandardResponse response = + StandardResponse.fail(errorCode.getStatus().value(), errorResponse); + + return ResponseEntity.status(errorCode.getStatus()).body(response); + } + + /** RequestParam, PathVariable 등 메서드 파라미터에서 제약 조건을 위반해 바인딩 실패 시 발생 */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstrainViolationException( + ConstraintViolationException e) { + log.error("ConstrainViolationException : {}", e.getMessage(), e); + + List bindingErrors = + e.getConstraintViolations().stream() + .map( + constraintViolation -> { + List propertyPath = + List.of( + constraintViolation + .getPropertyPath() + .toString() + .split("\\.")); + + String path = + propertyPath.stream() + .skip(propertyPath.size() - 1L) + .findFirst() + .orElse(null); + + return FieldErrorResponse.of( + path, constraintViolation.getMessage()); + }) + .toList(); + + final ErrorCode errorCode = CommonErrorCode.CONSTRAINT_VIOLATION; + final ErrorResponse errorResponse = + ErrorResponse.of(errorCode.getName(), errorCode.getMessage(), bindingErrors); + final StandardResponse response = + StandardResponse.fail(HttpStatus.BAD_REQUEST.value(), errorResponse); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + /** PathVariable, RequestParam, RequestHeader 에서 요청한 메서드 파라미터 타입이 일치하지 않은 경우 발생 */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + protected ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException e) { + log.error("MethodArgumentTypeMismatchException : {}", e.getMessage(), e); + + final ErrorCode errorCode = CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH; + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), errorCode.getMessage()); + final StandardResponse response = + StandardResponse.fail(errorCode.getStatus().value(), errorResponse); + + return ResponseEntity.status(errorCode.getStatus()).body(response); + } + + /** 지원하지 않는 HTTP method 요청 시 발생 */ + @Override + protected ResponseEntity handleHttpRequestMethodNotSupported( + HttpRequestMethodNotSupportedException e, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest webRequest) { + log.error("HttpRequestMethodNotSupportedException : {}", e.getMethod(), e); + + final ErrorCode errorCode = CommonErrorCode.METHOD_NOT_ALLOWED; + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), errorCode.getMessage()); + final StandardResponse response = + StandardResponse.fail(errorCode.getStatus().value(), errorResponse); + + return ResponseEntity.status(errorCode.getStatus()).body(response); + } +} diff --git a/src/main/java/com/ject/studytrip/global/exception/response/ErrorResponse.java b/src/main/java/com/ject/studytrip/global/exception/response/ErrorResponse.java new file mode 100644 index 0000000..2c5dd97 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/exception/response/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.ject.studytrip.global.exception.response; + +public record ErrorResponse(String error, String message, Object values) { + + public static ErrorResponse of(String error, String message, Object values) { + return new ErrorResponse(error, message, values); + } + + public static ErrorResponse of(String error, String message) { + return new ErrorResponse(error, message, null); + } +} diff --git a/src/main/java/com/ject/studytrip/global/exception/response/FieldErrorResponse.java b/src/main/java/com/ject/studytrip/global/exception/response/FieldErrorResponse.java new file mode 100644 index 0000000..aded05c --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/exception/response/FieldErrorResponse.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.global.exception.response; + +public record FieldErrorResponse(String field, String reason) { + + public static FieldErrorResponse of(String field, String reason) { + return new FieldErrorResponse(field, reason); + } +} diff --git a/src/main/java/com/ject/studytrip/global/security/CustomAccessDeniedHandler.java b/src/main/java/com/ject/studytrip/global/security/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..6a1381e --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/security/CustomAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package com.ject.studytrip.global.security; + +import com.ject.studytrip.global.exception.error.AuthErrorCode; +import com.ject.studytrip.global.exception.error.ErrorCode; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final SecurityResponseHandler securityResponseHandler; + + @Override + public void handle( + HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) + throws IOException, ServletException { + log.error("AccessDeniedException : {}", e.getMessage(), e); + + final ErrorCode errorCode = AuthErrorCode.ACCESS_DENIED; + securityResponseHandler.sendResponse(response, errorCode); + } +} diff --git a/src/main/java/com/ject/studytrip/global/security/CustomAuthenticationEntryPoint.java b/src/main/java/com/ject/studytrip/global/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..41a8d79 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,31 @@ +package com.ject.studytrip.global.security; + +import com.ject.studytrip.global.exception.error.AuthErrorCode; +import com.ject.studytrip.global.exception.error.ErrorCode; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final SecurityResponseHandler securityResponseHandler; + + @Override + public void commence( + HttpServletRequest request, HttpServletResponse response, AuthenticationException e) + throws IOException, ServletException { + log.error("AuthenticationException : {}", e.getMessage(), e); + + final ErrorCode errorCode = AuthErrorCode.UNAUTHENTICATED; + securityResponseHandler.sendResponse(response, errorCode); + } +} diff --git a/src/main/java/com/ject/studytrip/global/security/SecurityResponseHandler.java b/src/main/java/com/ject/studytrip/global/security/SecurityResponseHandler.java new file mode 100644 index 0000000..6cd8c55 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/security/SecurityResponseHandler.java @@ -0,0 +1,31 @@ +package com.ject.studytrip.global.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.global.exception.error.ErrorCode; +import com.ject.studytrip.global.exception.response.ErrorResponse; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SecurityResponseHandler { + + private static final String CONTENT_TYPE = "application/json;charset=UTF-8"; + + private final ObjectMapper objectMapper; + + public void sendResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + final ErrorResponse errorResponse = + ErrorResponse.of(errorCode.getName(), errorCode.getMessage()); + final StandardResponse standardResponse = + StandardResponse.fail(errorCode.getStatus().value(), errorResponse); + final String json = objectMapper.writeValueAsString(standardResponse); + + response.setStatus(standardResponse.status()); + response.setContentType(CONTENT_TYPE); + response.getWriter().write(json); + } +} diff --git a/src/main/java/com/ject/studytrip/sample/SampleController.java b/src/main/java/com/ject/studytrip/sample/SampleController.java new file mode 100644 index 0000000..2916d53 --- /dev/null +++ b/src/main/java/com/ject/studytrip/sample/SampleController.java @@ -0,0 +1,52 @@ +package com.ject.studytrip.sample; + +import com.ject.studytrip.global.common.response.StandardResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "sample", description = "샘플 API") +@RequestMapping("/api/sample") +@RestController +public class SampleController { + + @Operation(summary = "sampleGet", description = "GET 샘플 API 입니다.") + @GetMapping() + public ResponseEntity sampleGet(@RequestParam Long sampleId) { + StandardResponse response = StandardResponse.success(HttpStatus.OK.value(), sampleId); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation(summary = "samplePost", description = "POST 샘플 API 입니다.") + @PostMapping() + public ResponseEntity samplePost(@RequestBody @Valid SampleRequest request) { + SampleResponse sampleResponse = SampleResponse.of(request.sample(), request.sampleNum()); + StandardResponse response = + StandardResponse.success(HttpStatus.CREATED.value(), sampleResponse); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "samplePut", description = "PUT 샘플 API 입니다.") + @PutMapping("/{sampleId}") + public ResponseEntity samplePut(@PathVariable Long sampleId) { + StandardResponse response = StandardResponse.success(HttpStatus.OK.value(), null); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation(summary = "samplePatch", description = "PATCH 샘플 API 입니다.") + @PatchMapping("/{sampleId}") + public ResponseEntity samplePatch(@PathVariable Long sampleId) { + StandardResponse response = StandardResponse.success(HttpStatus.OK.value(), null); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation(summary = "sampleDelete", description = "DELETE 샘플 API 입니다.") + @DeleteMapping("/{sampleId}") + public ResponseEntity sampleDelete(@PathVariable Long sampleId) { + StandardResponse response = StandardResponse.success(HttpStatus.OK.value(), sampleId); + return ResponseEntity.status(HttpStatus.OK).body(response); + } +} diff --git a/src/main/java/com/ject/studytrip/sample/SampleRequest.java b/src/main/java/com/ject/studytrip/sample/SampleRequest.java new file mode 100644 index 0000000..9fb9272 --- /dev/null +++ b/src/main/java/com/ject/studytrip/sample/SampleRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.sample; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; + +public record SampleRequest( + @NotEmpty(message = "sample 은 필수입니다.") String sample, + @Min(value = 1, message = "sampleNum 은 1 이상이여야 합니다.") int sampleNum) {} diff --git a/src/main/java/com/ject/studytrip/sample/SampleResponse.java b/src/main/java/com/ject/studytrip/sample/SampleResponse.java new file mode 100644 index 0000000..7a2659e --- /dev/null +++ b/src/main/java/com/ject/studytrip/sample/SampleResponse.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.sample; + +public record SampleResponse(String sample, int sampleNum) { + + public static SampleResponse of(String sample, int sampleNum) { + return new SampleResponse(sample, sampleNum); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 47b06ae..9f40a0c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,6 +5,9 @@ spring: - redis - security +swagger: + version: ${SWAGGER_VERSION:1} + server-url: ${API_SERVER_URL:http://localhost:8080} springdoc: default-consumes-media-type: application/json;charset=UTF-8 default-produces-media-type: application/json;charset=UTF-8