diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1aab493 Binary files /dev/null and b/.DS_Store differ diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f216d79..6543e2c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -7,9 +7,11 @@ cd "$REPO_ROOT" case "$(uname -s)" in Darwin*|Linux*) + ./gradlew spotlessApply ./gradlew spotlessCheck ;; MINGW*|MSYS*) + ./gradlew.bat spotlessApply ./gradlew.bat spotlessCheck ;; *) @@ -26,4 +28,4 @@ if [ $RESULT -ne 0 ]; then else echo "코드 스타일 검사 통과" exit 0 -fi +fi \ No newline at end of file diff --git a/.github/workflows/develop.yaml.yml b/.github/workflows/develop.yaml.yml new file mode 100644 index 0000000..e637e8d --- /dev/null +++ b/.github/workflows/develop.yaml.yml @@ -0,0 +1,116 @@ +name: develop + +on: + pull_request: + branches: + - develop + push: + branches: + - develop + +env: + IMAGE_NAME: ghcr.io/ddiddit/didit-backend:latest + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build with Gradle + run: ./gradlew build + + - name: Upload JAR + if: github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: app-jar + path: build/libs/*.jar + retention-days: 1 + + docker-build: + needs: build + if: github.event_name == 'push' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Download JAR + uses: actions/download-artifact@v4 + with: + name: app-jar + path: build/libs/ + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Docker Image + run: docker build -t $IMAGE_NAME . + + - name: Push Docker Image + run: docker push $IMAGE_NAME + + deploy: + needs: docker-build + if: github.event_name == 'push' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Copy deploy scripts to NCP dev server + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + source: "deploy/*" + target: "/home/${{ secrets.DEV_USER }}/didit" + + - name: Deploy to dev server + uses: appleboy/ssh-action@v0.1.6 + env: + COMMIT_MSG: ${{ github.event.head_commit.message }} + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + envs: COMMIT_MSG + script: | + cd /home/${{ secrets.DEV_USER }}/didit + + chmod +x deploy/dev/scripts/*.sh + chmod +x deploy/shared/*.sh + + cat > deploy/dev/.env << 'ENVEOF' + SPRING_PROFILES_ACTIVE=dev + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_ROOT_PASSWORD=${{ secrets.DB_ROOT_PASSWORD }} + DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} + ENVEOF + + echo "DEPLOYER=${{ github.actor }}" >> deploy/dev/.env + printf 'COMMIT_MESSAGE=%s\n' "$(echo "$COMMIT_MSG" | head -1)" >> deploy/dev/.env + + chmod 600 deploy/dev/.env + + ./deploy/dev/scripts/deploy.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5a979af..44d2e86 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,4 @@ out/ .vscode/ ### Kotlin ### -.kotlin +.kotlin \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0517327 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM gradle:8.5-jdk21 AS build + +WORKDIR /app + +COPY . . + +RUN gradle bootJar -x test --no-daemon + +FROM eclipse-temurin:21-jre-jammy + +WORKDIR /app + +COPY --from=build /app/build/libs/*.jar app.jar + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 5842aaa..27dd8ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") developmentOnly("org.springframework.boot:spring-boot-docker-compose") @@ -46,6 +48,7 @@ dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc") testImplementation("com.tngtech.archunit:archunit-junit5:1.4.1") + testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -98,6 +101,8 @@ tasks.register("asciidoctorApp") { configurations("asciidoctorExt") baseDirFollowsSourceFile() + attributes(mapOf("snippets" to snippetsDir.absolutePath)) + setSourceDir(file("src/docs/asciidoc/app")) sources { diff --git a/deploy/dev/docker-compose.app.yaml b/deploy/dev/docker-compose.app.yaml new file mode 100644 index 0000000..5e4e42f --- /dev/null +++ b/deploy/dev/docker-compose.app.yaml @@ -0,0 +1,17 @@ +services: + didit-api: + image: ghcr.io/ddiddit/didit-backend:latest + container_name: didit-api + restart: always + environment: + SPRING_PROFILES_ACTIVE: dev + networks: + - didit-network + env_file: + - .env + ports: + - "127.0.0.1:8080:8080" + +networks: + didit-network: + external: true \ No newline at end of file diff --git a/deploy/dev/docker-compose.db.yaml b/deploy/dev/docker-compose.db.yaml new file mode 100644 index 0000000..14f6bb1 --- /dev/null +++ b/deploy/dev/docker-compose.db.yaml @@ -0,0 +1,33 @@ +services: + didit-dev-db: + image: mysql:8.0 + container_name: didit-dev-db + restart: always + networks: + - didit-network + ports: + - "127.0.0.1:3306:3306" + env_file: + - .env + environment: + MYSQL_DATABASE: didit + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + TZ: Asia/Seoul + volumes: + - mysql-data:/var/lib/mysql + command: + [ + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_unicode_ci", + "--default-time-zone=Asia/Seoul" + ] + +networks: + didit-network: + external: true + +volumes: + mysql-data: + driver: local diff --git a/deploy/dev/docker-compose.nginx.yaml b/deploy/dev/docker-compose.nginx.yaml new file mode 100644 index 0000000..6744f0f --- /dev/null +++ b/deploy/dev/docker-compose.nginx.yaml @@ -0,0 +1,17 @@ +services: + didit-nginx: + image: nginx:latest + container_name: didit-nginx + restart: always + networks: + - didit-network + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./certs:/etc/nginx/certs:ro + +networks: + didit-network: + external: true \ No newline at end of file diff --git a/deploy/dev/nginx.conf b/deploy/dev/nginx.conf new file mode 100644 index 0000000..995f56f --- /dev/null +++ b/deploy/dev/nginx.conf @@ -0,0 +1,25 @@ +events {} + +http { + server { + listen 80; + server_name dev-api.didit.ai.kr; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl; + server_name dev-api.didit.ai.kr; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + location / { + proxy_pass http://didit-api:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} \ No newline at end of file diff --git a/deploy/dev/scripts/deploy.sh b/deploy/dev/scripts/deploy.sh new file mode 100644 index 0000000..0a89fdf --- /dev/null +++ b/deploy/dev/scripts/deploy.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +REGISTRY="ghcr.io/ddiddit/didit-backend" +WORK_DIR="/home/didit-dev/didit" +DEPLOY_DIR="$WORK_DIR/deploy/dev" +APP_COMPOSE="docker-compose.app.yaml" +DB_COMPOSE="docker-compose.db.yaml" +HEALTH_CHECK_URL="http://localhost:8080/actuator/health" +MAX_RETRY=6 +RETRY_INTERVAL=10 + +ENV_FILE="$DEPLOY_DIR/.env" +if [ -f "$ENV_FILE" ]; then + while IFS='=' read -r key value; do + case "$key" in + DISCORD_WEBHOOK_URL|COMMIT_MESSAGE|DEPLOYER) + export "$key=$value" + ;; + esac + done < "$ENV_FILE" +fi + +cd "$WORK_DIR" || exit 1 + +echo -e "${YELLOW}[1/9] 현재 이미지를 롤백용으로 보관${NC}" +CURRENT_IMAGE=$(docker images ${REGISTRY}:latest -q || true) +if [ ! -z "${CURRENT_IMAGE:-}" ]; then + docker rmi ${REGISTRY}:previous 2>/dev/null || true + docker tag ${REGISTRY}:latest ${REGISTRY}:previous + echo -e "${GREEN}[SUCCESS] 롤백용 이미지 준비 완료${NC}" +else + echo -e "${YELLOW}[INFO] 기존 이미지 없음 (첫 배포)${NC}" +fi + +echo -e "${YELLOW}[2/9] 오래된 이미지 정리${NC}" +OLD_IMAGES=$(docker images ${REGISTRY} -q | tail -n +3 || true) +if [ ! -z "${OLD_IMAGES:-}" ]; then + echo "$OLD_IMAGES" | xargs docker rmi -f 2>/dev/null || true +fi + +echo -e "${YELLOW}[3/9] 최신 이미지 다운로드${NC}" +docker pull ${REGISTRY}:latest +NEW_IMAGE=$(docker images ${REGISTRY}:latest -q || true) +echo -e "${GREEN}[SUCCESS] 새 이미지: ${NEW_IMAGE}${NC}" + +cd "$DEPLOY_DIR" || exit 1 + +echo -e "${YELLOW}[4/9] DB 컨테이너 확인${NC}" +if ! docker ps --format '{{.Names}}' | grep -q '^didit-dev-db$'; then + docker compose -f "$DB_COMPOSE" up -d + echo -e "${GREEN}[SUCCESS] DB 컨테이너 시작 완료${NC}" +else + echo -e "${GREEN}[SUCCESS] DB 컨테이너 이미 실행 중${NC}" +fi + +echo -e "${YELLOW}[4/9] Nginx 컨테이너 확인${NC}" +if ! docker ps --format '{{.Names}}' | grep -q '^didit-nginx$'; then + docker compose -f "docker-compose.nginx.yaml" up -d + echo -e "${GREEN}[SUCCESS] Nginx 컨테이너 시작 완료${NC}" +else + echo -e "${GREEN}[SUCCESS] Nginx 컨테이너 이미 실행 중${NC}" +fi + +echo -e "${YELLOW}[5/9] 기존 APP 컨테이너 중지${NC}" +docker compose -f "$APP_COMPOSE" down 2>/dev/null || docker rm -f didit-api 2>/dev/null || true + +echo -e "${YELLOW}[6/9] 새 APP 컨테이너 시작${NC}" +docker compose -f "$APP_COMPOSE" up -d +if [ $? -ne 0 ]; then + echo -e "${RED}[ERROR] 시작 실패 - 롤백 시작${NC}" + "$DEPLOY_DIR/scripts/rollback.sh" + exit 1 +fi + +echo -e "${YELLOW}[7/9] 애플리케이션 시작 대기${NC}" +sleep 10 + +echo -e "${YELLOW}[8/9] 헬스체크${NC}" +RETRY_COUNT=0 +HEALTH_OK=false +while [ $RETRY_COUNT -lt $MAX_RETRY ]; do + if curl -f -s --max-time 5 "$HEALTH_CHECK_URL" > /dev/null 2>&1; then + HEALTH_OK=true + break + else + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo -e "${YELLOW}[RETRY] (${RETRY_COUNT}/${MAX_RETRY})${NC}" + [ $RETRY_COUNT -eq 3 ] && docker logs --tail 30 didit-api || true + sleep $RETRY_INTERVAL + fi +done + +if [ "$HEALTH_OK" = false ]; then + echo -e "${RED}[ERROR] 헬스체크 실패 - 롤백 시작${NC}" + docker logs --tail 100 didit-api || true + "$DEPLOY_DIR/scripts/rollback.sh" + exit 1 +fi + +echo -e "${YELLOW}[9/9] 정리${NC}" +docker image prune -f || true + +if [ ! -z "${DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then + source "$WORK_DIR/deploy/shared/discord-notify.sh" + notify_deploy_success "$NEW_IMAGE" "$COMMIT_MESSAGE" "$DEPLOYER" "dev" +fi + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} 배포 완료!${NC}" +echo -e "${GREEN}========================================${NC}" \ No newline at end of file diff --git a/deploy/dev/scripts/rollback.sh b/deploy/dev/scripts/rollback.sh new file mode 100644 index 0000000..3d20792 --- /dev/null +++ b/deploy/dev/scripts/rollback.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +REGISTRY="ghcr.io/ddiddit/didit-backend" +WORK_DIR="/home/didit-dev/didit" +DEPLOY_DIR="$WORK_DIR/deploy/dev" +APP_COMPOSE="docker-compose.app.yaml" +HEALTH_CHECK_URL="http://localhost:8080/actuator/health" +MAX_RETRY=6 +RETRY_INTERVAL=10 + +if [ -f "$DEPLOY_DIR/.env" ]; then + DISCORD_WEBHOOK_URL="$(grep -E '^DISCORD_WEBHOOK_URL=' "$DEPLOY_DIR/.env" | tail -n 1 | cut -d= -f2- || true)" + export DISCORD_WEBHOOK_URL +fi + +echo -e "${YELLOW}[1/6] 롤백용 이미지 확인${NC}" +PREVIOUS_IMAGE=$(docker images ${REGISTRY}:previous -q || true) +if [ -z "${PREVIOUS_IMAGE:-}" ]; then + echo -e "${RED}[ERROR] 롤백할 이전 이미지가 없습니다.${NC}" + exit 1 +fi + +echo -e "${YELLOW}[2/6] 현재 APP 컨테이너 중지${NC}" +cd "$DEPLOY_DIR" || exit 1 +docker compose -f "$APP_COMPOSE" down 2>/dev/null || docker rm -f didit-api 2>/dev/null || true + +echo -e "${YELLOW}[3/6] 이전 버전으로 태그 변경${NC}" +docker tag ${REGISTRY}:previous ${REGISTRY}:latest + +echo -e "${YELLOW}[4/6] 이전 버전 APP 컨테이너 시작${NC}" +docker compose -f "$APP_COMPOSE" up -d + +echo -e "${YELLOW}[5/6] 시작 대기${NC}" +sleep 10 + +echo -e "${YELLOW}[6/6] 헬스체크${NC}" +RETRY_COUNT=0 +HEALTH_OK=false +while [ $RETRY_COUNT -lt $MAX_RETRY ]; do + if curl -f -s --max-time 5 "$HEALTH_CHECK_URL" > /dev/null 2>&1; then + HEALTH_OK=true + break + else + RETRY_COUNT=$((RETRY_COUNT + 1)) + sleep $RETRY_INTERVAL + fi +done + +if [ "$HEALTH_OK" = true ]; then + echo -e "${GREEN} 롤백 성공${NC}" + if [ ! -z "${DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then + source "$WORK_DIR/deploy/shared/discord-notify.sh" + notify_rollback_success "dev" + fi +else + echo -e "${RED} 롤백 실패 - 수동 복구 필요${NC}" + if [ ! -z "${DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then + source "$WORK_DIR/deploy/shared/discord-notify.sh" + notify_rollback_failed "dev" + fi + exit 1 +fi \ No newline at end of file diff --git a/deploy/shared/discored-notify.sh b/deploy/shared/discored-notify.sh new file mode 100644 index 0000000..2ee1fe3 --- /dev/null +++ b/deploy/shared/discored-notify.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +COLOR_RED=15158332 +COLOR_GREEN=3066993 +COLOR_YELLOW=16776960 + +notify_deploy_success() { + local image_id=$1 + local commit_message=${2:-""} + local deployer=${3:-""} + local env=${4:-"dev"} + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + [ -z "$DISCORD_WEBHOOK_URL" ] && return + + PAYLOAD=$(jq -n \ + --arg image_id "$image_id" \ + --arg msg "$commit_message" \ + --arg ts "$timestamp" \ + --arg deployer "$deployer" \ + --arg env "$env" \ + --argjson color "$COLOR_GREEN" \ + '{username: "Didit Bot", embeds: [{title: ("배포 완료 [" + $env + "]"), description: $msg, color: $color, fields: [{name: "담당자", value: $deployer, inline: true}, {name: "시간", value: $ts, inline: true}, {name: "이미지", value: $image_id, inline: false}]}]}') + curl -s -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL" > /dev/null +} + +notify_rollback_success() { + local env=${1:-"dev"} + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + [ -z "$DISCORD_WEBHOOK_URL" ] && return + + PAYLOAD=$(jq -n --arg ts "$timestamp" --arg env "$env" --argjson color "$COLOR_GREEN" \ + '{username: "Didit Bot", embeds: [{title: ("롤백 성공 [" + $env + "]"), description: "이전 버전으로 복구되었습니다.", color: $color, fields: [{name: "시간", value: $ts, inline: true}]}]}') + curl -s -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL" > /dev/null +} + +notify_rollback_failed() { + local env=${1:-"dev"} + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + [ -z "$DISCORD_WEBHOOK_URL" ] && return + + PAYLOAD=$(jq -n --arg ts "$timestamp" --arg env "$env" --argjson color "$COLOR_RED" \ + '{username: "Didit Bot", content: "@here 롤백 실패 - 수동 복구 필요", embeds: [{title: ("롤백 실패 [" + $env + "]"), color: $color, fields: [{name: "시간", value: $ts, inline: true}]}]}') + curl -s -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL" > /dev/null +} + +notify_server_down() { + local env=${1:-"dev"} + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + [ -z "$DISCORD_WEBHOOK_URL" ] && return + + PAYLOAD=$(jq -n --arg ts "$timestamp" --arg env "$env" --argjson color "$COLOR_RED" \ + '{username: "Didit Bot", content: "@here 서버 다운", embeds: [{title: ("서버 다운 [" + $env + "]"), color: $color, fields: [{name: "시간", value: $ts, inline: true}]}]}') + curl -s -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL" > /dev/null +} + +notify_server_up() { + local downtime=${1:-""} + local env=${2:-"dev"} + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + [ -z "$DISCORD_WEBHOOK_URL" ] && return + + PAYLOAD=$(jq -n --arg ts "$timestamp" --arg dt "$downtime" --arg env "$env" --argjson color "$COLOR_GREEN" \ + '{username: "Didit Bot", embeds: [{title: ("서버 복구 [" + $env + "]"), color: $color, fields: [{name: "다운타임", value: $dt, inline: true}, {name: "복구 시간", value: $ts, inline: true}]}]}') + curl -s -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL" > /dev/null +} \ No newline at end of file diff --git a/src/docs/asciidoc/app/index.adoc b/src/docs/asciidoc/app/index.adoc index a5eae0b..6fda75c 100644 --- a/src/docs/asciidoc/app/index.adoc +++ b/src/docs/asciidoc/app/index.adoc @@ -8,3 +8,5 @@ :sectanchors: :snippets: ../../../build/generated-snippets + +include::../shared/overview.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/shared/overview.adoc b/src/docs/asciidoc/shared/overview.adoc new file mode 100644 index 0000000..f79a826 --- /dev/null +++ b/src/docs/asciidoc/shared/overview.adoc @@ -0,0 +1,45 @@ +== Overview + +=== 성공 응답 구조 + +==== 데이터 응답 (200, 201) + +include::{snippets}/response/success-data/http-response.adoc[] +include::{snippets}/response/success-data/response-fields.adoc[] + +==== 빈 응답 (204) + +삭제 등 응답 바디가 없는 경우 `204 No Content` 를 반환합니다. + +=== 에러 응답 구조 + +모든 에러 응답은 RFC 9457 ProblemDetail 형식을 따릅니다. + +==== 비즈니스 예외 + +include::{snippets}/error/business/http-response.adoc[] +include::{snippets}/error/business/response-fields.adoc[] + +==== 유효성 검증 실패 + +include::{snippets}/error/validation/http-response.adoc[] +include::{snippets}/error/validation/response-fields.adoc[] + +==== 서버 에러 + +include::{snippets}/error/server/http-response.adoc[] +include::{snippets}/error/server/response-fields.adoc[] + +=== 에러 코드 + +|=== +| 코드 | HTTP 상태 | 설명 + +| INVALID_REQUEST +| 400 +| 잘못된 요청 + +| INTERNAL_SERVER_ERROR +| 500 +| 서버 내부 오류 +|=== \ No newline at end of file diff --git a/src/main/kotlin/com/didit/adapter/webapi/response/SuccessResponse.kt b/src/main/kotlin/com/didit/adapter/webapi/response/SuccessResponse.kt index 53f642e..4185658 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/response/SuccessResponse.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/response/SuccessResponse.kt @@ -1,32 +1,9 @@ package com.didit.adapter.webapi.response -import com.fasterxml.jackson.annotation.JsonInclude - -@JsonInclude(JsonInclude.Include.NON_NULL) data class SuccessResponse( - val data: T? = null, - val message: String? = null, + val data: T, ) { companion object { - fun of( - data: T, - message: String, - ): SuccessResponse = - SuccessResponse( - data = data, - message = message, - ) - - fun of(data: T): SuccessResponse = - SuccessResponse( - data = data, - message = null, - ) - - fun of(message: String): SuccessResponse = - SuccessResponse( - data = null, - message = message, - ) + fun of(data: T): SuccessResponse = SuccessResponse(data = data) } } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..d0d3bf8 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,37 @@ +spring: + docker: + compose: + enabled: false + + datasource: + url: jdbc:mysql://didit-dev-db:3306/didit?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${DB_USER} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: true + open-in-view: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: always + +logging: + level: + root: INFO + com.didit: DEBUG \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 266a22a..2e2200a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,6 +7,15 @@ spring: compose: enabled: false +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never + logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{requestId}] %logger - %msg%n" diff --git a/src/test/kotlin/com/didit/DiditApiApplicationTests.kt b/src/test/kotlin/com/didit/DiditApiApplicationTests.kt deleted file mode 100644 index de9a3ab..0000000 --- a/src/test/kotlin/com/didit/DiditApiApplicationTests.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.didit - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class DiditApiApplicationTests { - @Test - fun contextLoads() { - } -} diff --git a/src/test/kotlin/com/didit/docs/ApiDocsTest.kt b/src/test/kotlin/com/didit/docs/ApiDocsTest.kt new file mode 100644 index 0000000..24fda1c --- /dev/null +++ b/src/test/kotlin/com/didit/docs/ApiDocsTest.kt @@ -0,0 +1,84 @@ +package com.didit.docs + +import com.didit.support.TestController +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +class ApiDocsTest : RestDocsSupport() { + override fun initController() = TestController() + + @Test + fun `데이터 성공 응답 문서화`() { + mockMvc + .perform(get("/test/success-data")) + .andExpect(status().isOk) + .andDo( + document( + "response/success-data", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields( + *CommonDocumentation.successResponseFields( + fieldWithPath("data.id").type(JsonFieldType.STRING).description("ID"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름"), + ), + ), + ), + ) + } + + @Test + fun `비즈니스 예외 응답 문서화`() { + mockMvc + .perform(get("/test/business-error")) + .andExpect(status().isBadRequest) + .andDo( + document( + "error/business", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields(*CommonDocumentation.errorResponseFields()), + ), + ) + } + + @Test + fun `유효성 검증 실패 응답 문서화`() { + mockMvc + .perform( + post("/test/validation-error") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name": ""}"""), + ).andExpect(status().isBadRequest) + .andDo( + document( + "error/validation", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields(*CommonDocumentation.errorResponseFields()), + ), + ) + } + + @Test + fun `서버 에러 응답 문서화`() { + mockMvc + .perform(get("/test/server-error")) + .andExpect(status().isInternalServerError) + .andDo( + document( + "error/server", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields(*CommonDocumentation.errorResponseFields()), + ), + ) + } +} diff --git a/src/test/kotlin/com/didit/docs/ApiDocumentUtils.kt b/src/test/kotlin/com/didit/docs/ApiDocumentUtils.kt new file mode 100644 index 0000000..63d9fa4 --- /dev/null +++ b/src/test/kotlin/com/didit/docs/ApiDocumentUtils.kt @@ -0,0 +1,13 @@ +package com.didit.docs + +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse +import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint + +object ApiDocumentUtils { + fun getDocumentRequest(): OperationRequestPreprocessor = preprocessRequest(prettyPrint()) + + fun getDocumentResponse(): OperationResponsePreprocessor = preprocessResponse(prettyPrint()) +} diff --git a/src/test/kotlin/com/didit/docs/CommonDocumentation.kt b/src/test/kotlin/com/didit/docs/CommonDocumentation.kt new file mode 100644 index 0000000..e873b62 --- /dev/null +++ b/src/test/kotlin/com/didit/docs/CommonDocumentation.kt @@ -0,0 +1,27 @@ +package com.didit.docs + +import org.springframework.restdocs.payload.FieldDescriptor +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath + +object CommonDocumentation { + fun successResponseFields(vararg dataFields: FieldDescriptor): Array { + val baseFields = + arrayOf( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + ) + return baseFields + dataFields + } + + fun errorResponseFields(): Array = + arrayOf( + fieldWithPath("type").type(JsonFieldType.STRING).description("에러 타입"), + fieldWithPath("title").type(JsonFieldType.STRING).description("HTTP 상태 메시지"), + fieldWithPath("status").type(JsonFieldType.NUMBER).description("HTTP 상태 코드"), + fieldWithPath("detail").type(JsonFieldType.STRING).description("에러 상세 메시지"), + fieldWithPath("instance").type(JsonFieldType.STRING).description("요청 URI"), + fieldWithPath("properties").type(JsonFieldType.OBJECT).description("추가 정보"), + fieldWithPath("properties.timestamp").type(JsonFieldType.STRING).description("에러 발생 시간"), + fieldWithPath("properties.code").type(JsonFieldType.STRING).description("에러 코드"), + ) +} diff --git a/src/test/kotlin/com/didit/docs/RestDocsSupport.kt b/src/test/kotlin/com/didit/docs/RestDocsSupport.kt new file mode 100644 index 0000000..2e0c4ba --- /dev/null +++ b/src/test/kotlin/com/didit/docs/RestDocsSupport.kt @@ -0,0 +1,41 @@ +package com.didit.docs + +import com.didit.adapter.webapi.exception.ApiControllerAdvice +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.restdocs.RestDocumentationContextProvider +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean + +@ExtendWith(RestDocumentationExtension::class) +abstract class RestDocsSupport { + protected lateinit var mockMvc: MockMvc + protected val objectMapper: ObjectMapper = + ObjectMapper() + .registerModule(JavaTimeModule()) + .registerModule(KotlinModule.Builder().build()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @BeforeEach + fun setUp(provider: RestDocumentationContextProvider) { + mockMvc = + MockMvcBuilders + .standaloneSetup(initController()) + .setControllerAdvice(ApiControllerAdvice()) + .setMessageConverters(MappingJackson2HttpMessageConverter(objectMapper)) + .setValidator(LocalValidatorFactoryBean().also { it.afterPropertiesSet() }) + .apply(documentationConfiguration(provider)) + .build() + } + + abstract fun initController(): Any +} diff --git a/src/test/kotlin/com/didit/support/TestController.kt b/src/test/kotlin/com/didit/support/TestController.kt new file mode 100644 index 0000000..79b9f80 --- /dev/null +++ b/src/test/kotlin/com/didit/support/TestController.kt @@ -0,0 +1,35 @@ +package com.didit.support + +import com.didit.adapter.webapi.response.SuccessResponse +import com.didit.application.common.exception.BusinessException +import com.didit.application.common.exception.ErrorCode +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("/test") +@RestController +class TestController { + @GetMapping("/success-data") + fun successData(): SuccessResponse> = SuccessResponse.of(mapOf("id" to "1", "name" to "테스트")) + + @GetMapping("/business-error") + fun businessError(): String = throw BusinessException(ErrorCode.INVALID_REQUEST) + + @GetMapping("/server-error") + fun serverError(): String = throw RuntimeException("서버 에러") + + @PostMapping("/validation-error") + fun validationError( + @Valid @RequestBody request: TestRequest, + ): String = "ok" +} + +data class TestRequest( + @field:NotBlank(message = "이름은 필수입니다") + val name: String?, +)