diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md new file mode 100644 index 0000000..72b8a71 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -0,0 +1,11 @@ +--- +name: Issue template +about: Describe this issue purpose here. +title: "" +labels: "" +assignees: "" +--- + +## 목적 + +## 세부 내용 설명 \ No newline at end of file diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..901aece --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,50 @@ +name: CD + +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission + run: chmod +x ./gradlew + + - name: Build + run: ./gradlew clean build -x test + + - name: Login Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build & Push Image + run: | + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/mua-backend:${{ github.sha }} . + docker push ${{ secrets.DOCKERHUB_USERNAME }}/mua-backend:${{ github.sha }} + + - name: Deploy to WAS + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.WAS_HOST }} + username: ${{ secrets.WAS_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.WAS_SSH_PORT }} + script: | + docker stop mua-backend || true + docker rm mua-backend || true + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/mua-backend:${{ github.sha }} + docker run -d -p 8080:8080 --name mua-backend \ + ${{ secrets.DOCKERHUB_USERNAME }}/mua-backend:${{ github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..27ee7a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches-ignore: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission + run: chmod +x ./gradlew + + - name: Build (no deploy) + run: ./gradlew clean build -x test diff --git a/.gitignore b/.gitignore index c2065bc..a885259 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ out/ ### VS Code ### .vscode/ + +*.yml +!application.yml +!ci.yml +!cd.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fc18ae9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +# 1. JDK 이미지를 기반으로 실행 환경 설정 +FROM eclipse-temurin:17-jdk-alpine + +# 2. JAR 파일을 컨테이너 안으로 복사 +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar + +# 3. 컨테이너 실행 시 실행할 명령어 +ENTRYPOINT ["java","-jar","/app.jar"] diff --git a/build.gradle b/build.gradle index cd60f13..a8e3afc 100644 --- a/build.gradle +++ b/build.gradle @@ -25,11 +25,34 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Spring Boot + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + // Database + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // AWS + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0' + } tasks.named('test') { diff --git a/src/main/java/Mua/Mua_backend/global/annotation/ApiExceptions.java b/src/main/java/Mua/Mua_backend/global/annotation/ApiExceptions.java new file mode 100644 index 0000000..7fc9eb2 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/annotation/ApiExceptions.java @@ -0,0 +1,14 @@ +package Mua.Mua_backend.global.annotation; + +import Mua.Mua_backend.global.exception.ErrorCode; + +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 ApiExceptions { + ErrorCode[] value(); +} diff --git a/src/main/java/Mua/Mua_backend/global/annotation/EnumValid.java b/src/main/java/Mua/Mua_backend/global/annotation/EnumValid.java new file mode 100644 index 0000000..03c419d --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/annotation/EnumValid.java @@ -0,0 +1,19 @@ +package Mua.Mua_backend.global.annotation; + +import Mua.Mua_backend.global.common.EnumValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint(validatedBy = EnumValidator.class) +public @interface EnumValid { + + String message() default ""; + Class[] groups() default {}; + Class[] payload() default {}; + Class> enumClass(); +} diff --git a/src/main/java/Mua/Mua_backend/global/common/BaseTimeEntity.java b/src/main/java/Mua/Mua_backend/global/common/BaseTimeEntity.java new file mode 100644 index 0000000..95f3576 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/common/BaseTimeEntity.java @@ -0,0 +1,24 @@ +package Mua.Mua_backend.global.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime lastModifiedDate; +} diff --git a/src/main/java/Mua/Mua_backend/global/common/EnumValidator.java b/src/main/java/Mua/Mua_backend/global/common/EnumValidator.java new file mode 100644 index 0000000..d2aaa7a --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/common/EnumValidator.java @@ -0,0 +1,27 @@ +package Mua.Mua_backend.global.common; + +import Mua.Mua_backend.global.annotation.EnumValid; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.Arrays; + +public class EnumValidator implements ConstraintValidator { + + private Class> enumClass; + + @Override + public void initialize(EnumValid constraintAnnotation) { + this.enumClass = constraintAnnotation.enumClass(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + return Arrays.stream(enumClass.getEnumConstants()) + .anyMatch(e -> e.name().equalsIgnoreCase(value)); // 대소문자 구분 없음 + } +} diff --git a/src/main/java/Mua/Mua_backend/global/config/S3StorageConfig.java b/src/main/java/Mua/Mua_backend/global/config/S3StorageConfig.java new file mode 100644 index 0000000..6ce39c4 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/config/S3StorageConfig.java @@ -0,0 +1,23 @@ +package Mua.Mua_backend.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3StorageConfig { + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/src/main/java/Mua/Mua_backend/global/config/SecurityConfig.java b/src/main/java/Mua/Mua_backend/global/config/SecurityConfig.java new file mode 100644 index 0000000..e137d0a --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/config/SecurityConfig.java @@ -0,0 +1,65 @@ +package Mua.Mua_backend.global.config; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +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.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/login/**", "/oauth2/**", "/login/oauth2/**", + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + // 예외 처리 설정 + .exceptionHandling(exception -> exception + .authenticationEntryPoint((req, res, ex) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED)) + .accessDeniedHandler((req, res, ex) -> res.sendError(HttpServletResponse.SC_FORBIDDEN)) + ); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(List.of( + "http://localhost:3000" + )); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + +} diff --git a/src/main/java/Mua/Mua_backend/global/config/SwaggerConfig.java b/src/main/java/Mua/Mua_backend/global/config/SwaggerConfig.java new file mode 100644 index 0000000..549bb3e --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/config/SwaggerConfig.java @@ -0,0 +1,30 @@ +package Mua.Mua_backend.global.config; + +import Mua.Mua_backend.global.swagger.OpenApiConfigurer; +import Mua.Mua_backend.global.swagger.SwaggerErrorExampleGenerator; +import Mua.Mua_backend.global.swagger.SwaggerOperationCustomizer; +import io.swagger.v3.oas.models.OpenAPI; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Value("${spring.swagger.url}") + private String baseUrl; + + @Bean + public OpenAPI openAPI() { return new OpenApiConfigurer(baseUrl).createOpenAPI(); } + + @Bean + public SwaggerErrorExampleGenerator swaggerErrorExampleGenerator() { + return new SwaggerErrorExampleGenerator(); + } + + @Bean + public OperationCustomizer swaggerOperationCustomizer(SwaggerErrorExampleGenerator generator) { + return new SwaggerOperationCustomizer(generator); + } +} diff --git a/src/main/java/Mua/Mua_backend/global/exception/CustomException.java b/src/main/java/Mua/Mua_backend/global/exception/CustomException.java new file mode 100644 index 0000000..6bd19c3 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/exception/CustomException.java @@ -0,0 +1,14 @@ +package Mua.Mua_backend.global.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException{ + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/Mua/Mua_backend/global/exception/ErrorCode.java b/src/main/java/Mua/Mua_backend/global/exception/ErrorCode.java new file mode 100644 index 0000000..b0f1fbe --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/exception/ErrorCode.java @@ -0,0 +1,22 @@ +package Mua.Mua_backend.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + INVALID_INPUT(400, "잘못된 입력 값입니다."), + UNAUTHORIZED(401, "인증이 필요합니다."), + FORBIDDEN(403, "접근 권한이 없습니다."), + NOT_FOUND(404, "존재하지 않는 리소스입니다."), + INTERNAL_SERVER_ERROR(500, "서버 오류가 발생했습니다."), + + // example + EXAMPLE_NOT_FOUND(404, "예시를 찾을 수 없습니다."); + + private final int status; + private final String message; + +} diff --git a/src/main/java/Mua/Mua_backend/global/exception/ErrorResponse.java b/src/main/java/Mua/Mua_backend/global/exception/ErrorResponse.java new file mode 100644 index 0000000..8410722 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/exception/ErrorResponse.java @@ -0,0 +1,14 @@ +package Mua.Mua_backend.global.exception; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Builder +public class ErrorResponse { + + private final int status; + private final String message; +} diff --git a/src/main/java/Mua/Mua_backend/global/exception/GlobalExceptionHandler.java b/src/main/java/Mua/Mua_backend/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..2efeeeb --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,24 @@ +package Mua.Mua_backend.global.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + ErrorCode code = e.getErrorCode(); + return ResponseEntity + .status(code.getStatus()) + .body(new ErrorResponse(code.getStatus(), code.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception e) { + return ResponseEntity + .status(500) + .body(new ErrorResponse(500, e.getMessage())); + } +} diff --git a/src/main/java/Mua/Mua_backend/global/s3/controller/S3Controller.java b/src/main/java/Mua/Mua_backend/global/s3/controller/S3Controller.java new file mode 100644 index 0000000..b5d7707 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/s3/controller/S3Controller.java @@ -0,0 +1,33 @@ +package Mua.Mua_backend.global.s3.controller; + +import Mua.Mua_backend.global.s3.service.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URL; +import java.util.Map; + +@RestController +@RequestMapping +@RequiredArgsConstructor +public class S3Controller { + + private final S3Service s3Service; + + @GetMapping("/presigned") + public Map getPreSignedUploadUrl( + @RequestParam String type, + @RequestParam String contentType + ) { + String key = s3Service.createKey(type); + URL url = s3Service.generatePreSignedUploadUrl(key, contentType); + + return Map.of( + "uploadUrl", url.toString(), + "fileKey", key + ); + } +} \ No newline at end of file diff --git a/src/main/java/Mua/Mua_backend/global/s3/service/S3Service.java b/src/main/java/Mua/Mua_backend/global/s3/service/S3Service.java new file mode 100644 index 0000000..3744a22 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/s3/service/S3Service.java @@ -0,0 +1,48 @@ +package Mua.Mua_backend.global.s3.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +import java.net.URL; +import java.time.Duration; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class S3Service { + + private final S3Presigner presigner; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public String createKey(String type) { + String uuid = UUID.randomUUID().toString(); + + return switch (type) { + case "feed" -> "feeds/" + uuid + ".png"; + case "profile" -> "profiles/" + uuid + ".png"; + default -> throw new IllegalArgumentException("지원하지 않는 이미지 타입"); + }; + } + + public URL generatePreSignedUploadUrl(String key, String contentType) { + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .build(); + + PresignedPutObjectRequest presignedRequest = + presigner.presignPutObject(builder -> builder + .signatureDuration(Duration.ofMinutes(5)) + .putObjectRequest(objectRequest) + ); + + return presignedRequest.url(); + } +} diff --git a/src/main/java/Mua/Mua_backend/global/swagger/ExampleHolder.java b/src/main/java/Mua/Mua_backend/global/swagger/ExampleHolder.java new file mode 100644 index 0000000..65d025c --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/swagger/ExampleHolder.java @@ -0,0 +1,14 @@ +package Mua.Mua_backend.global.swagger; + +import io.swagger.v3.oas.models.examples.Example; +import lombok.Builder; + +@Builder +public record ExampleHolder ( + + Example holder, + String name, + int code + +) { +} diff --git a/src/main/java/Mua/Mua_backend/global/swagger/OpenApiConfigurer.java b/src/main/java/Mua/Mua_backend/global/swagger/OpenApiConfigurer.java new file mode 100644 index 0000000..3a5facc --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/swagger/OpenApiConfigurer.java @@ -0,0 +1,56 @@ +package Mua.Mua_backend.global.swagger; + +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.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +import java.util.List; + +public class OpenApiConfigurer { + + private final String baseUrl; + + public OpenApiConfigurer(String baseUrl){ + this.baseUrl = baseUrl; + } + + public OpenAPI createOpenAPI() { + return new OpenAPI() + .info(createInfo()) + .addSecurityItem(createSecurityRequirement()) + .components(createComponents()) + .servers(createServerList()); + } + + private Info createInfo() { + return new Info() + .title("MuaCon API 문서") + .version("v1.0.0") + .description("MuaCon서비스 API 문서입니다."); + } + + private SecurityRequirement createSecurityRequirement() { + return new SecurityRequirement().addList("JWT"); + } + + private Components createComponents() { + return new Components() + .addSecuritySchemes("JWT", + new SecurityScheme() + .name("JWT") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT 토큰을 입력하세요.(Bearer XXX)")); + } + + private List createServerList() { + return List.of( + new Server().description("Production Server").url(baseUrl), + new Server().description("Local Server").url("http://localhost:8080") + ); + } +} diff --git a/src/main/java/Mua/Mua_backend/global/swagger/SwaggerErrorExampleGenerator.java b/src/main/java/Mua/Mua_backend/global/swagger/SwaggerErrorExampleGenerator.java new file mode 100644 index 0000000..93a1858 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/swagger/SwaggerErrorExampleGenerator.java @@ -0,0 +1,72 @@ +package Mua.Mua_backend.global.swagger; + +import Mua.Mua_backend.global.exception.ErrorCode; +import Mua.Mua_backend.global.exception.ErrorResponse; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +// ErrorCode 목록을 받아서 Swagger가 이해할 수 있는 에러응답 예제로 바꿔서 operation(Response)에 꽂아주는 도구 +public class SwaggerErrorExampleGenerator { + + public void addErrorResponse(Operation operation, ErrorCode[] errorCodes) { + ApiResponses responses = operation.getResponses(); + Map> statusWithExampleHolders = groupByStatus(errorCodes); + addExamplesToResponses(responses, statusWithExampleHolders); + } + + private Map> groupByStatus(ErrorCode[] errorCodes) { + return Arrays.stream(errorCodes) + .map(this::createExampleHolder) + .collect(Collectors.groupingBy(ExampleHolder::code)); + } + + private ExampleHolder createExampleHolder(ErrorCode errorCode) { + return ExampleHolder.builder() + .holder(createSwaggerExample(errorCode)) + .code(errorCode.getStatus()) + .name(errorCode.name()) + .build(); + } + + private Example createSwaggerExample(ErrorCode errorCode) { + ErrorResponse errorResponse = ErrorResponse.builder() + .status(errorCode.getStatus()) + .message(errorCode.getMessage()) + .build(); + + Example example = new Example(); + example.setValue(errorResponse); + return example; + } + + private void addExamplesToResponses(ApiResponses responses, Map> statusWithExampleHolders) { + statusWithExampleHolders.forEach((status, exampleHolders) -> { + ApiResponse apiResponse = createApiResponseWithExamples(exampleHolders); + responses.addApiResponse(String.valueOf(status), apiResponse); + }); + } + + private ApiResponse createApiResponseWithExamples(List exampleHolders) { + MediaType mediaType = new MediaType(); + exampleHolders.forEach(holder -> + mediaType.addExamples(holder.name(), holder.holder()) + ); + + Content content = new Content(); + content.addMediaType("application/json", mediaType); + + ApiResponse apiResponse = new ApiResponse(); + apiResponse.setContent(content); + return apiResponse; + } + +} diff --git a/src/main/java/Mua/Mua_backend/global/swagger/SwaggerOperationCustomizer.java b/src/main/java/Mua/Mua_backend/global/swagger/SwaggerOperationCustomizer.java new file mode 100644 index 0000000..24fac30 --- /dev/null +++ b/src/main/java/Mua/Mua_backend/global/swagger/SwaggerOperationCustomizer.java @@ -0,0 +1,44 @@ +package Mua.Mua_backend.global.swagger; + +import Mua.Mua_backend.global.annotation.ApiExceptions; +import io.swagger.v3.oas.models.Operation; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.web.method.HandlerMethod; + +import java.lang.reflect.Method; + +@RequiredArgsConstructor +public class SwaggerOperationCustomizer implements OperationCustomizer { + + private final SwaggerErrorExampleGenerator errorExampleGenerator; + + @Override + public Operation customize(Operation operation, HandlerMethod handlerMethod) { + ApiExceptions apiExceptions = resolveApiExceptions(handlerMethod); + + if (apiExceptions != null) { + errorExampleGenerator.addErrorResponse(operation, apiExceptions.value()); + } + + return operation; + } + + //현재 실행 중인 컨트롤러 메서드에 @ApiExceptions 어노테이션이 붙어있으면 그걸 찾고, 없으면 인터페이스에 붙어있는 걸 대신 찾아서 반환한다. + private ApiExceptions resolveApiExceptions(HandlerMethod handlerMethod) { + ApiExceptions ann = handlerMethod.getMethodAnnotation(ApiExceptions.class); + if (ann != null) return ann; + + Method method = handlerMethod.getMethod(); + Class beanType = handlerMethod.getBeanType(); + for (Class i : beanType.getInterfaces()) { + try { + Method interfaceMethod = i.getMethod(method.getName(), method.getParameterTypes()); + ApiExceptions fromInterface = interfaceMethod.getAnnotation(ApiExceptions.class); + if (fromInterface != null) return fromInterface; + } catch (NoSuchMethodException ignored) { + } + } + return null; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index cfe53b5..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=Mua_backend diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..81533ef --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,8 @@ +spring: + profiles: + active: dev + # active: prod + include: security, swagger, S3 + +server: + port: 8080 \ No newline at end of file