diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..9c7e095a Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/cd-wordflow.yml b/.github/workflows/cd-wordflow.yml new file mode 100644 index 00000000..1299200e --- /dev/null +++ b/.github/workflows/cd-wordflow.yml @@ -0,0 +1,66 @@ +# 자바 프로젝트를 위한 Gradle을 사용한 지속적 배포(CD) 워크플로우 설정 +name: CD with Gradle + +# main 브랜치로 push되거나 pull request가 발생했을 때 워크플로우를 실행 +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +# 워크플로우에서는 저장소 내용을 읽을 수 있는 권한만 설정 +permissions: + contents: read + +# 워크플로우에서 실행될 작업들을 정의 +jobs: + build: + runs-on: ubuntu-latest # 작업을 실행할 환경으로 ubuntu 최신 버전을 사용 + + steps: + # 실행주체: GitHub Actions > GitHub Infra + # 첫 번째 단계: 워크플로우를 트리거한 커밋에서 저장소 코드를 체크아웃 + - uses: actions/checkout@v3 + + # 두 번째 단계: Java Development Kit(JDK) 버전 17을 설정 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: "adopt" + + # 세 번째 단계: GitHub secrets에서 데이터를 가져와 application.properties 파일 생성 +# - name: Make application.properties +# run: | +# cd ./src/main/resources +# touch ./application.properties +# echo "${{ secrets.PROPERTIES }}" > ./application.properties +# shell: bash + + # 네 번째 단계: Gradle을 사용해 프로젝트 빌드, 테스트는 제외 + - name: Build with Gradle + run: | + chmod +x ./gradlew + ./gradlew clean build -x test + + # 다섯 번째 단계: Docker 이미지를 빌드하고 Docker 저장소에 푸시 + - name: Docker build & push to docker repo + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -f Dockerfile -t ${{ secrets.DOCKER_REPO }}/directors-dev . + docker push ${{ secrets.DOCKER_REPO }}/directors-dev + # 실행주체: GitHub Actions > GitHub Infra // End + + # 여섯 번째 단계: SSH를 사용하여 서버에 배포 + - name: Deploy to server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ubuntu + key: ${{ secrets.KEY }} + envs: GITHUB_SHA + script: | + sudo docker rm -f $(docker ps -qa) + sudo docker pull ${{ secrets.DOCKER_REPO }}/directors-dev + docker-compose up -d + docker image prune -f diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..2096516a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# 사용할 Java JDK 버전을 선택 +FROM openjdk:17-oracle + +COPY ./build/libs/*-SNAPSHOT.jar app.jar + +ARG ENVIRONMENT + +ENV SPRIING_PROFIlES_ACTIVE=${ENVIRONMENT} + +ENV TZ Asia/Seoul + +# 컨테이너가 시작될 때 실행할 명령어 +ENTRYPOINT ["java","-jar","/app.jar"] diff --git a/build.gradle b/build.gradle index 232c5699..a7b231b1 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,16 @@ repositories { } dependencies { + // Swagger start + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'javax.xml.bind:jaxb-api:2.3.1' // xml 에러 방지 + // Swagger end + + // spring security start + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + // spring security end + implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/src/main/java/com/gamemoonchul/common/ObjectMapperConfig.java b/src/main/java/com/gamemoonchul/common/ObjectMapperConfig.java new file mode 100644 index 00000000..6cc75841 --- /dev/null +++ b/src/main/java/com/gamemoonchul/common/ObjectMapperConfig.java @@ -0,0 +1,37 @@ +package com.gamemoonchul.common; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObjectMapperConfig { + // Spring이 실행될 때 Object Mapper를 불러오는데 설정된 Object Mapper가 없으면 + // Spring이 임의적으로 Object Mapper를 생성하고 설정된 Object Mapper가 있다면 설정된 Mapper를 불러오게 된다. + @Bean + public ObjectMapper objectMapper() { + var objectMapper = new ObjectMapper(); + // JDK 버전 이후 클래스 사용 가능 + objectMapper.registerModule(new Jdk8Module()); + + objectMapper.registerModule(new JavaTimeModule()); + + // 모르는 Json Value에 대해서는 무시하고 나머지 값만 파싱한다. + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + // 비어있는 Bean을 만들 때 + objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + // 날짜 관련 직렬화 설정 해제 + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + // 스네이크 케이스 + // objectMapper.setPropertyNamingStrategy(new PropertyNamingStrategies.SnakeCaseStrategy()); + + return objectMapper; + } +} diff --git a/src/main/java/com/gamemoonchul/common/config/SpringSecurityConfig.java b/src/main/java/com/gamemoonchul/common/config/SpringSecurityConfig.java new file mode 100644 index 00000000..42357398 --- /dev/null +++ b/src/main/java/com/gamemoonchul/common/config/SpringSecurityConfig.java @@ -0,0 +1,37 @@ +package com.gamemoonchul.common.config; + +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +import java.util.List; + +@Configuration +@EnableWebSecurity +public class SpringSecurityConfig { + private List SWAGGER = List.of("/swagger-ui.html", "/swagger-ui/**", "/swagger-resources/**", "/v2/api-docs", "/v3/api-docs/**"); + private List EXCEPTION = List.of("/test/**"); + + @Bean + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity.authorizeHttpRequests(it -> { + // static Resource에 대해서는 모두 허가함 + it.requestMatchers( + PathRequest.toStaticResources() + .atCommonLocations() + ) + .permitAll() + .requestMatchers(SWAGGER.toArray(new String[0])) + .permitAll() + .requestMatchers(EXCEPTION.toArray(new String[0])) + .permitAll() + .anyRequest() + .authenticated(); + }); + return httpSecurity.build(); + } +} diff --git a/src/main/java/com/gamemoonchul/common/config/SwaggerConfig.java b/src/main/java/com/gamemoonchul/common/config/SwaggerConfig.java new file mode 100644 index 00000000..dfb842e3 --- /dev/null +++ b/src/main/java/com/gamemoonchul/common/config/SwaggerConfig.java @@ -0,0 +1,19 @@ +package com.gamemoonchul.common.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("Gamemuncheol Project API") + .description("게임문철 프로젝트의 API 문서입니다.") + .version("1.0.0")); + } +} \ No newline at end of file diff --git a/src/main/java/com/gamemoonchul/common/exception/ApiException.java b/src/main/java/com/gamemoonchul/common/exception/ApiException.java new file mode 100644 index 00000000..bced0073 --- /dev/null +++ b/src/main/java/com/gamemoonchul/common/exception/ApiException.java @@ -0,0 +1,27 @@ +package com.gamemoonchul.common.exception; + +import com.gamemoonchul.common.status.ApiStatusIfs; +import lombok.Getter; + +@Getter +public class ApiException extends RuntimeException implements ApiExceptionIfs { + /*** + * Custom하게 생성된 ApiStatus + * { + * "status" : { + * "statusCode" : 400, + * "message" : "Bad Request" + * }, + * "detail" : "파라미터의 인자값이 잘못되었습니다." + * } + */ + private final ApiStatusIfs status; + // 좀 더 세부적인 Error Detail이 필요할 경우 + private final String detail; + + public ApiException(ApiStatusIfs status) { + super(status.getMessage()); + this.status = status; + this.detail = status.getMessage(); + } +} diff --git a/src/main/java/com/gamemoonchul/common/exception/ApiExceptionIfs.java b/src/main/java/com/gamemoonchul/common/exception/ApiExceptionIfs.java new file mode 100644 index 00000000..0a80c175 --- /dev/null +++ b/src/main/java/com/gamemoonchul/common/exception/ApiExceptionIfs.java @@ -0,0 +1,8 @@ +package com.gamemoonchul.common.exception; + +import com.gamemoonchul.common.status.ApiStatusIfs; + +public interface ApiExceptionIfs { + ApiStatusIfs getStatus(); + String getDetail(); +} diff --git a/src/main/java/com/gamemoonchul/common/exceptionHandler/ApiExceptionHandler.java b/src/main/java/com/gamemoonchul/common/exceptionHandler/ApiExceptionHandler.java new file mode 100644 index 00000000..5ebd062d --- /dev/null +++ b/src/main/java/com/gamemoonchul/common/exceptionHandler/ApiExceptionHandler.java @@ -0,0 +1,29 @@ +package com.gamemoonchul.common.exceptionHandler; + +import com.gamemoonchul.common.exception.ApiException; +import com.gamemoonchul.infrastructure.web.common.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class ApiExceptionHandler { + @ExceptionHandler(value = ApiException.class) + public ResponseEntity apiException( + ApiException apiException + ) { + log.error("", apiException); + + int customErrorCode = apiException.getStatus().getStatusCode(); + HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + if((customErrorCode % 1000) / 100 == 4) { + httpStatus = HttpStatus.BAD_REQUEST; + } + + return ResponseEntity.status(httpStatus) + .body(ApiResponse.ERROR(apiException.getStatus())); + } +} diff --git a/src/main/java/com/gamemoonchul/common/status/ApiStatus.java b/src/main/java/com/gamemoonchul/common/status/ApiStatus.java new file mode 100644 index 00000000..23b2b281 --- /dev/null +++ b/src/main/java/com/gamemoonchul/common/status/ApiStatus.java @@ -0,0 +1,17 @@ +package com.gamemoonchul.common.status; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ApiStatus implements ApiStatusIfs{ + OK(200, "OK"), + BAD_REQUEST(400, "Bad Request"), + SERVER_ERROR(500, "Server Error"), + NULL_POINT(512, "Null Point Error"), + ; + + private final Integer statusCode; + private final String message; +} diff --git a/src/main/java/com/gamemoonchul/common/status/ApiStatusIfs.java b/src/main/java/com/gamemoonchul/common/status/ApiStatusIfs.java new file mode 100644 index 00000000..4a72011b --- /dev/null +++ b/src/main/java/com/gamemoonchul/common/status/ApiStatusIfs.java @@ -0,0 +1,13 @@ +package com.gamemoonchul.common.status; + +/*** + * Custom하게 정의되서 사용할 ApiStatus의 Interface + * 예) UserApiStatus의 경우 2000번대로 시작하게 된다면 + * statusCode : 2404 + * message : "User Not Found" + * 이런식으로 된다. + */ +public interface ApiStatusIfs { + public Integer getStatusCode(); + public String getMessage(); +} diff --git a/src/main/java/com/gamemoonchul/infrastructure/web/ApiTestController.java b/src/main/java/com/gamemoonchul/infrastructure/web/ApiTestController.java new file mode 100644 index 00000000..49f0f004 --- /dev/null +++ b/src/main/java/com/gamemoonchul/infrastructure/web/ApiTestController.java @@ -0,0 +1,23 @@ +package com.gamemoonchul.infrastructure.web; + +import com.gamemoonchul.infrastructure.web.common.RestControllerWithEnvelopPattern; +import com.gamemoonchul.infrastructure.web.common.dto.ApiTest; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +@AllArgsConstructor +@RestControllerWithEnvelopPattern +@RequestMapping("/test") +public class ApiTestController { + + private final ApiTestService apiTestService; + + @GetMapping("/{bool}") + public ApiTest hello( + @PathVariable boolean bool + ) { + return apiTestService.hello(bool); + } +} diff --git a/src/main/java/com/gamemoonchul/infrastructure/web/ApiTestService.java b/src/main/java/com/gamemoonchul/infrastructure/web/ApiTestService.java new file mode 100644 index 00000000..b74f3868 --- /dev/null +++ b/src/main/java/com/gamemoonchul/infrastructure/web/ApiTestService.java @@ -0,0 +1,16 @@ +package com.gamemoonchul.infrastructure.web; + +import com.gamemoonchul.common.exception.ApiException; +import com.gamemoonchul.common.status.ApiStatus; +import com.gamemoonchul.infrastructure.web.common.dto.ApiTest; +import org.springframework.stereotype.Service; + +@Service +public class ApiTestService { + public ApiTest hello(boolean bool) { + if (bool) { + return ApiTest.mock(); + } + throw new ApiException(ApiStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/com/gamemoonchul/infrastructure/web/common/ApiResponse.java b/src/main/java/com/gamemoonchul/infrastructure/web/common/ApiResponse.java new file mode 100644 index 00000000..1eeed2a4 --- /dev/null +++ b/src/main/java/com/gamemoonchul/infrastructure/web/common/ApiResponse.java @@ -0,0 +1,48 @@ +package com.gamemoonchul.infrastructure.web.common; + +import com.gamemoonchul.common.status.ApiStatus; +import com.gamemoonchul.common.status.ApiStatusIfs; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +public class ApiResponse { + private boolean success; + private ApiStatusInfo status; + private T data; + + // Builder + public ApiResponse(boolean success, T data, ApiStatusIfs status) { + this.success = success; + this.data = data; + this.status = new ApiStatusInfo(status); + } + + @Getter + @AllArgsConstructor + public static class ApiStatusInfo { + private final Integer statusCode; + private final String message; + + public ApiStatusInfo(ApiStatusIfs status) { + this.statusCode = status.getStatusCode(); + this.message = status.getMessage(); + } + } + + public static ApiResponse OK() { + return new ApiResponse( + true, + null, + ApiStatus.OK + ); + } + + public static ApiResponse OK(T data) { + return new ApiResponse<>(true, data, ApiStatus.OK); + } + + public static ApiResponse ERROR(ApiStatusIfs status) { + return new ApiResponse<>(false, null, status); + } +} diff --git a/src/main/java/com/gamemoonchul/infrastructure/web/common/ApiResponseWrappingResponseBodyAdvisor.java b/src/main/java/com/gamemoonchul/infrastructure/web/common/ApiResponseWrappingResponseBodyAdvisor.java new file mode 100644 index 00000000..67f3b5a1 --- /dev/null +++ b/src/main/java/com/gamemoonchul/infrastructure/web/common/ApiResponseWrappingResponseBodyAdvisor.java @@ -0,0 +1,30 @@ +package com.gamemoonchul.infrastructure.web.common; + +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +import java.util.Optional; + +@RestControllerAdvice(annotations = RestControllerWithEnvelopPattern.class) +public class ApiResponseWrappingResponseBodyAdvisor implements ResponseBodyAdvice { + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + // 컨트롤러의 반환타입이 객체일 때는 직렬화를 위해 MappingJackson2HttpMessageConverter를 사용한다. + // 따라서, MappingJackson2HttpMessageConverter를 사용하는 경우에만 beforeBodyWrite 메서드를 호출하도록 한다. + return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType); + } + + @Override + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { + if (Optional.ofNullable(body).isPresent()) { + return ApiResponse.OK(body); + } + return ApiResponse.OK(); + } +} diff --git a/src/main/java/com/gamemoonchul/infrastructure/web/common/RestControllerWithEnvelopPattern.java b/src/main/java/com/gamemoonchul/infrastructure/web/common/RestControllerWithEnvelopPattern.java new file mode 100644 index 00000000..4736dc03 --- /dev/null +++ b/src/main/java/com/gamemoonchul/infrastructure/web/common/RestControllerWithEnvelopPattern.java @@ -0,0 +1,13 @@ +package com.gamemoonchul.infrastructure.web.common; + +import org.springframework.web.bind.annotation.RestController; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@RestController +public @interface RestControllerWithEnvelopPattern { +} diff --git a/src/main/java/com/gamemoonchul/infrastructure/web/common/dto/ApiTest.java b/src/main/java/com/gamemoonchul/infrastructure/web/common/dto/ApiTest.java new file mode 100644 index 00000000..5824192a --- /dev/null +++ b/src/main/java/com/gamemoonchul/infrastructure/web/common/dto/ApiTest.java @@ -0,0 +1,17 @@ +package com.gamemoonchul.infrastructure.web.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ApiTest { + private String name; + private int age; + + public static ApiTest mock() { + return new ApiTest("김한글", 24); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b137891..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 00000000..2b563f73 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + security: + user: + name: user + password: 1234