diff --git a/.github/workflows/dev-cd.yaml b/.github/workflows/dev-cd.yaml index 13879c5..a167833 100644 --- a/.github/workflows/dev-cd.yaml +++ b/.github/workflows/dev-cd.yaml @@ -24,6 +24,19 @@ jobs: aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} aws-region: ${{ env.AWS_REGION }} + - name: Get Runner IP + id: ip + run: echo "runner_ip=$(curl -s https://checkip.amazonaws.com)" >> $GITHUB_OUTPUT + + - name: Add Runner IP to Security Group + run: | + aws ec2 authorize-security-group-ingress \ + --group-id ${{ secrets.DEV_SECURITY_GROUP_ID }} \ + --protocol tcp \ + --port 22 \ + --cidr ${{ steps.ip.outputs.runner_ip }}/32 \ + --region ${{ env.AWS_REGION }} + - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 @@ -74,7 +87,7 @@ jobs: --memory="700m" \ --memory-swap="1400m" \ --cpus="1.5" \ - --health-cmd="wget --no-verbose --tries=1 --spider http://localhost:8080/api/v1/actuator/health || exit 1" \ + --health-cmd="wget --no-verbose --tries=1 --spider http://localhost:8080/api/actuator/health || exit 1" \ --health-interval=30s \ --health-timeout=5s \ --health-retries=3 \ @@ -97,3 +110,13 @@ jobs: docker logs $CONTAINER_NAME --tail 30 docker image prune -af --filter "until=24h" + + - name: Remove Runner IP from Security Group + if: always() + run: | + aws ec2 revoke-security-group-ingress \ + --group-id ${{ secrets.DEV_SECURITY_GROUP_ID }} \ + --protocol tcp \ + --port 22 \ + --cidr ${{ steps.ip.outputs.runner_ip }}/32 \ + --region ${{ env.AWS_REGION }} || true diff --git a/build.gradle.kts b/build.gradle.kts index d796b0a..63c4806 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,11 +24,13 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-restclient") + implementation("org.springframework.boot:spring-boot-starter-validation") // implementation("org.springframework.boot:spring-boot-starter-security") // implementation("org.springframework.boot:spring-boot-starter-security-oauth2-client") implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("tools.jackson.module:jackson-module-kotlin") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0") developmentOnly("org.springframework.boot:spring-boot-docker-compose") runtimeOnly("org.postgresql:postgresql") testRuntimeOnly("com.h2database:h2") diff --git a/infra/Dockerfile b/infra/Dockerfile index 8ea4ea4..62c9146 100644 --- a/infra/Dockerfile +++ b/infra/Dockerfile @@ -34,9 +34,6 @@ USER spring:spring EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 - ENV JAVA_OPTS="-XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -XX:InitialRAMPercentage=50.0 \ diff --git a/infra/dev/Caddyfile b/infra/dev/Caddyfile index 209f0ce..2e3a4df 100644 --- a/infra/dev/Caddyfile +++ b/infra/dev/Caddyfile @@ -5,7 +5,7 @@ lokit.co.kr { } reverse_proxy localhost:8080 { - health_uri /api/v1/actuator/health + health_uri /api/actuator/health health_interval 10s health_timeout 5s diff --git a/src/main/kotlin/kr/co/lokit/api/common/dto/ApiResponse.kt b/src/main/kotlin/kr/co/lokit/api/common/dto/ApiResponse.kt new file mode 100644 index 0000000..2948dee --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/common/dto/ApiResponse.kt @@ -0,0 +1,73 @@ +package kr.co.lokit.api.common.dto + +import jakarta.servlet.http.HttpServletRequest +import kr.co.lokit.api.common.exception.ErrorCode +import org.springframework.http.HttpStatus + +data class ApiResponse( + val code: Int, + val message: String, + val data: T, +) { + companion object { + data class ErrorDetail( + val errorCode: String, + val detail: String, + val instance: String, + val errors: Map? = null, + ) + + fun success( + code: Int, + data: T, + ): ApiResponse = + ApiResponse( + code = code, + message = "success", + data = data, + ) + + fun failure( + exception: Exception, + request: HttpServletRequest, + errorCode: ErrorCode, + errors: Map? = null, + ): ApiResponse { + val errorDetail = + ErrorDetail( + errorCode = errorCode.code, + detail = exception.message ?: errorCode.message, + instance = request.requestURI, + errors = errors, + ) + + return ApiResponse( + code = errorCode.status.value(), + message = errorCode.status.reasonPhrase, + data = errorDetail, + ) + } + + fun failure( + status: HttpStatus, + detail: String, + request: HttpServletRequest, + errorCode: String, + errors: Map? = null, + ): ApiResponse { + val errorDetail = + ErrorDetail( + errorCode = errorCode, + detail = detail, + instance = request.requestURI, + errors = errors, + ) + + return ApiResponse( + code = status.value(), + message = status.reasonPhrase, + data = errorDetail, + ) + } + } +} diff --git a/src/main/kotlin/kr/co/lokit/api/common/exception/BusinessException.kt b/src/main/kotlin/kr/co/lokit/api/common/exception/BusinessException.kt new file mode 100644 index 0000000..6a0387a --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/common/exception/BusinessException.kt @@ -0,0 +1,37 @@ +package kr.co.lokit.api.common.exception + +sealed class BusinessException( + val errorCode: ErrorCode, + override val message: String = errorCode.message, + override val cause: Throwable? = null, +) : RuntimeException(message, cause) { + class InvalidInputException( + message: String = ErrorCode.INVALID_INPUT.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.INVALID_INPUT, message, cause) + + class ResourceNotFoundException( + message: String = ErrorCode.RESOURCE_NOT_FOUND.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.RESOURCE_NOT_FOUND, message, cause) + + class ResourceAlreadyExistsException( + message: String = ErrorCode.RESOURCE_ALREADY_EXISTS.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.RESOURCE_ALREADY_EXISTS, message, cause) + + class UnauthorizedException( + message: String = ErrorCode.UNAUTHORIZED.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.UNAUTHORIZED, message, cause) + + class ForbiddenException( + message: String = ErrorCode.FORBIDDEN.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.FORBIDDEN, message, cause) + + class BusinessRuleViolationException( + message: String = ErrorCode.BUSINESS_RULE_VIOLATION.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.BUSINESS_RULE_VIOLATION, message, cause) +} diff --git a/src/main/kotlin/kr/co/lokit/api/common/exception/ErrorCode.kt b/src/main/kotlin/kr/co/lokit/api/common/exception/ErrorCode.kt new file mode 100644 index 0000000..5251570 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/common/exception/ErrorCode.kt @@ -0,0 +1,29 @@ +package kr.co.lokit.api.common.exception + +import org.springframework.http.HttpStatus + +enum class ErrorCode( + val status: HttpStatus, + val code: String, + val message: String, +) { + // Common + INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다"), + INVALID_TYPE(HttpStatus.BAD_REQUEST, "COMMON_002", "잘못된 타입입니다"), + MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "COMMON_003", "필수 파라미터가 누락되었습니다"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_001", "지원하지 않는 HTTP 메서드입니다"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_001", "서버 내부 오류가 발생했습니다"), + + // Authentication & Authorization + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH_001", "인증이 필요합니다"), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_002", "유효하지 않은 토큰입니다"), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_003", "만료된 토큰입니다"), + FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH_001", "접근 권한이 없습니다"), + + // Resource + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "RESOURCE_001", "요청한 리소스를 찾을 수 없습니다"), + RESOURCE_ALREADY_EXISTS(HttpStatus.CONFLICT, "RESOURCE_001", "이미 존재하는 리소스입니다"), + + // Business + BUSINESS_RULE_VIOLATION(HttpStatus.BAD_REQUEST, "BUSINESS_001", "비즈니스 규칙 위반입니다"), +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/advice/ApiResponseAdvice.kt b/src/main/kotlin/kr/co/lokit/api/config/advice/ApiResponseAdvice.kt new file mode 100644 index 0000000..7d7f07a --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/advice/ApiResponseAdvice.kt @@ -0,0 +1,55 @@ +package kr.co.lokit.api.config.advice + +import kr.co.lokit.api.common.dto.ApiResponse +import org.springframework.core.MethodParameter +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ProblemDetail +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse +import org.springframework.http.server.ServletServerHttpResponse +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice + +@RestControllerAdvice(basePackages = ["kr.co.lokit.api"]) +class ApiResponseAdvice : ResponseBodyAdvice { + companion object { + private val EXCLUDED_TYPES = + setOf( + ProblemDetail::class.java, + ApiResponse::class.java, + ResponseEntity::class.java, + String::class.java, + Void.TYPE, + Unit::class.java, + ) + } + + override fun supports( + returnType: MethodParameter, + converterType: Class>, + ): Boolean = returnType.parameterType !in EXCLUDED_TYPES + + override fun beforeBodyWrite( + body: Any?, + returnType: MethodParameter, + selectedContentType: MediaType, + selectedConverterType: Class>, + request: ServerHttpRequest, + response: ServerHttpResponse, + ): Any? { + val statusCode = + if (response is ServletServerHttpResponse) { + response.servletResponse.status + } else { + HttpStatus.OK.value() + } + + return ApiResponse.success( + code = statusCode, + data = body, + ) + } +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/advice/ErrorControllerAdvice.kt b/src/main/kotlin/kr/co/lokit/api/config/advice/ErrorControllerAdvice.kt new file mode 100644 index 0000000..fdc5e58 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/advice/ErrorControllerAdvice.kt @@ -0,0 +1,155 @@ +package kr.co.lokit.api.config.advice + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kr.co.lokit.api.common.dto.ApiResponse +import kr.co.lokit.api.common.dto.ApiResponse.Companion.ErrorDetail +import kr.co.lokit.api.common.exception.BusinessException +import kr.co.lokit.api.common.exception.ErrorCode +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.validation.BindException +import org.springframework.web.HttpRequestMethodNotSupportedException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.servlet.resource.NoResourceFoundException + +@RestControllerAdvice +class ErrorControllerAdvice { + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(BusinessException::class) + fun handleBusinessException( + ex: BusinessException, + request: HttpServletRequest, + response: HttpServletResponse, + ): ApiResponse { + response.status = ex.errorCode.status.value() + + return ApiResponse.failure( + exception = ex, + request = request, + errorCode = ex.errorCode, + ) + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleMethodArgumentNotValidException( + ex: MethodArgumentNotValidException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = ErrorCode.INVALID_INPUT.message, + request = request, + errorCode = ErrorCode.INVALID_INPUT.code, + errors = + ex.bindingResult.fieldErrors.associate { + it.field to (it.defaultMessage ?: ex::class.java.name) + }, + ) + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(BindException::class) + fun handleBindException( + ex: BindException, + request: HttpServletRequest, + ): ApiResponse { + val errors = + ex.bindingResult.fieldErrors.associate { + it.field to (it.defaultMessage ?: ex::class.java.name) + } + + return ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = ErrorCode.INVALID_INPUT.message, + request = request, + errorCode = ErrorCode.INVALID_INPUT.code, + errors = errors, + ) + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MissingServletRequestParameterException::class) + fun handleMissingServletRequestParameterException( + ex: MissingServletRequestParameterException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = "${ex.parameterName} 파라미터가 필요합니다", + request = request, + errorCode = ErrorCode.MISSING_PARAMETER.code, + ) + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handleMethodArgumentTypeMismatchException( + ex: MethodArgumentTypeMismatchException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = "${ex.name} 파라미터의 타입이 올바르지 않습니다", + request = request, + errorCode = ErrorCode.INVALID_TYPE.code, + ) + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleHttpMessageNotReadableException( + ex: HttpMessageNotReadableException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = "요청 본문을 읽을 수 없습니다. JSON 형식을 확인해주세요", + request = request, + errorCode = ErrorCode.INVALID_INPUT.code, + ) + + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + fun handleHttpRequestMethodNotSupportedException( + ex: HttpRequestMethodNotSupportedException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.METHOD_NOT_ALLOWED, + detail = "${ex.method} 메서드는 지원하지 않습니다", + request = request, + errorCode = ErrorCode.METHOD_NOT_ALLOWED.code, + ) + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NoResourceFoundException::class) + fun handleNoResourceFoundException( + ex: NoResourceFoundException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.NOT_FOUND, + detail = "요청한 리소스를 찾을 수 없습니다", + request = request, + errorCode = ErrorCode.RESOURCE_NOT_FOUND.code, + ) + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception::class) + fun handleException( + ex: Exception, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.INTERNAL_SERVER_ERROR, + detail = ErrorCode.INTERNAL_SERVER_ERROR.message, + request = request, + errorCode = ErrorCode.INTERNAL_SERVER_ERROR.code, + ) +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/docs/OpenApiConfig.kt b/src/main/kotlin/kr/co/lokit/api/config/docs/OpenApiConfig.kt new file mode 100644 index 0000000..789a4d9 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/docs/OpenApiConfig.kt @@ -0,0 +1,38 @@ +package kr.co.lokit.api.config.docs + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.servers.Server +import org.springdoc.core.models.GroupedOpenApi +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfig { + @Value("\${server.servlet.context-path:/}") + private lateinit var contextPath: String + + @Bean + fun openApi(): OpenAPI = + OpenAPI() + .info( + Info() + .title("Lokit API") + .version("1.0.0") + .description("Lokit API 문서"), + ) + .servers( + listOf( + Server().url(contextPath).description("API Server"), + ), + ) + + @Bean + fun apiGroup(): GroupedOpenApi = + GroupedOpenApi + .builder() + .group("api") + .packagesToScan("kr.co.lokit.api") + .build() +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/web/WebMvcConfig.kt b/src/main/kotlin/kr/co/lokit/api/config/web/WebMvcConfig.kt new file mode 100644 index 0000000..5939735 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/web/WebMvcConfig.kt @@ -0,0 +1,15 @@ +package kr.co.lokit.api.config.web + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun configureApiVersioning(configurer: ApiVersionConfigurer) { + configurer.apply { + useRequestHeader("X-API-VERSION") + setDefaultVersion("1.0") + } + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index d1c8936..369aaff 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -32,3 +32,14 @@ management: endpoint: health: show-details: when-authorized + +springdoc: + api-docs: + enabled: true + path: /docs + swagger-ui: + enabled: true + path: /swagger + display-request-duration: true + default-consumes-media-type: application/json + default-produces-media-type: application/json diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 3763d6b..56c29ed 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -11,6 +11,8 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true + show_sql: true + use_sql_comments: true jdbc: time_zone: Asia/Seoul open-in-view: false @@ -22,6 +24,8 @@ spring: logging: level: + org.hibernate.orm.jdbc.bind: trace + root: INFO kr.co.lokit.api: DEBUG management: @@ -30,3 +34,14 @@ management: exposure: include: "health" base-path: /actuator + +springdoc: + api-docs: + enabled: true + path: /docs + swagger-ui: + enabled: true + path: /swagger + display-request-duration: true + default-consumes-media-type: application/json + default-produces-media-type: application/json diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 65af616..15cf506 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -5,10 +5,11 @@ spring: active: ${SPRING_PROFILES_ACTIVE} jackson: time-zone: Asia/Seoul + default-property-inclusion: non_null server: servlet: - context-path: /api/v1 + context-path: /api logging: pattern: diff --git a/src/test/resources/appliction.yaml b/src/test/resources/appliction.yaml index 6d22b8b..4a11ff4 100644 --- a/src/test/resources/appliction.yaml +++ b/src/test/resources/appliction.yaml @@ -34,4 +34,4 @@ logging: server: servlet: - context-path: /api/v1 + context-path: /api