diff --git a/.github/workflows/database-docs.yml b/.github/workflows/database-docs.yml index a58726d..95ba84d 100644 --- a/.github/workflows/database-docs.yml +++ b/.github/workflows/database-docs.yml @@ -4,13 +4,21 @@ on: push: branches: [main] paths: + - 'build.gradle' + - 'src/main/java/**' + - 'src/main/resources/application.yaml' - 'src/main/resources/db/migration/**' + - 'scripts/api-docs/**' - 'scripts/db-docs/**' - '.github/workflows/database-docs.yml' pull_request: branches: [main] paths: + - 'build.gradle' + - 'src/main/java/**' + - 'src/main/resources/application.yaml' - 'src/main/resources/db/migration/**' + - 'scripts/api-docs/**' - 'scripts/db-docs/**' - '.github/workflows/database-docs.yml' workflow_dispatch: @@ -74,10 +82,51 @@ jobs: if-no-files-found: error retention-days: 14 + build-api-docs: + name: Build API documentation + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Java 17 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + - name: Set up Node.js 24 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + + - name: Test API documentation generator + run: node --test scripts/api-docs/generate-site.test.mjs + + - name: Generate API documentation + run: ./scripts/api-docs/generate.sh + env: + API_DOCS_GIT_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Upload API documentation preview + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: api-docs-site + path: build/api-docs/site + if-no-files-found: error + retention-days: 14 + deploy-pages: - name: Deploy database documentation + name: Deploy server documentation if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: build-docs + needs: + - build-docs + - build-api-docs runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -95,6 +144,12 @@ jobs: name: database-docs-site path: site + - name: Download API documentation + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: api-docs-site + path: site/api + - name: Configure GitHub Pages uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 diff --git a/README.md b/README.md index 70e315a..b32147b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ API 문서는 아래 주소에서 확인합니다. - Swagger UI: - OpenAPI JSON: +- 팀 공유용 Swagger HTML: +- 공유 문서 사용법: [API 문서 사용법](docs/api-documentation.md) - H2 Console(local 전용): local은 기본 Profile이라 별도 데이터베이스가 필요하지 않습니다. 서버를 다시 실행하면 메모리 DB가 초기화되고 Flyway migration이 처음부터 적용됩니다. H2 Console 보호를 위해 local 서버는 기본적으로 내 PC(`127.0.0.1`)에서만 접근할 수 있습니다. diff --git a/docs/api-documentation.md b/docs/api-documentation.md new file mode 100644 index 0000000..5d477d1 --- /dev/null +++ b/docs/api-documentation.md @@ -0,0 +1,69 @@ +# API 문서 사용법 + +FOWOCO Server의 공유용 Swagger HTML은 `main`에서 실제로 생성되는 OpenAPI +JSON을 읽기 쉬운 화면으로 변환한 문서입니다. + +- 팀 공유 사이트: +- OpenAPI JSON: +- 로컬 Swagger UI: + +## 어떤 문서인가요? + +| 문서 | 용도 | +| --- | --- | +| 공유용 Swagger HTML | 서버를 실행하지 않고 현재 `main`의 API를 확인 | +| OpenAPI JSON | Client 코드 생성, 계약 비교, 다른 도구에서 불러오기 | +| 로컬 Swagger UI | 개발 중인 브랜치의 API 확인과 직접 요청 테스트 | + +공유 사이트는 의도하지 않은 API 실행을 막기 위해 `Try it out`을 비활성화한 +읽기 전용 문서입니다. 실제 요청 테스트는 본인의 로컬 서버에서 진행합니다. + +## 언제 갱신되나요? + +Controller, 요청·응답 DTO, OpenAPI 설정 또는 문서 생성기가 변경되어 `main`에 +병합되면 GitHub Actions가 다음 순서로 갱신합니다. + +```text +Spring Boot test profile 실행 +→ /v3/api-docs에서 OpenAPI JSON 추출 +→ JSON 기본 구조 검증 +→ Swagger HTML 생성 +→ DB 문서와 하나의 GitHub Pages 사이트로 배포 +``` + +운영 서버의 Swagger는 보안상 계속 비활성화합니다. 공유 사이트는 운영 서버에 +접속하지 않으며 test profile과 메모리 DB만 사용합니다. + +## PR에서 먼저 확인하기 + +1. PR의 `Checks`에서 `Database Documentation` Workflow를 엽니다. +2. `Build API documentation` 결과가 성공했는지 확인합니다. +3. 실행 결과 아래 `api-docs-site` Artifact를 내려받습니다. +4. 압축을 풀고 `index.html`을 브라우저로 엽니다. + +Artifact는 외부 CDN에서 Swagger UI 정적 파일을 불러오므로 인터넷 연결이 +필요합니다. API 명세 자체는 HTML 안에도 포함되어 있어 별도 서버가 필요하지 +않습니다. + +## 로컬에서 생성하기 + +Java 17, Node.js와 `curl`이 필요합니다. + +```bash +./scripts/api-docs/generate.sh +open build/api-docs/site/index.html +``` + +기본 `18080` 포트가 사용 중이면 다른 포트를 지정할 수 있습니다. + +```bash +API_DOCS_PORT=18081 ./scripts/api-docs/generate.sh +``` + +## 보안 원칙 + +- 운영·Staging DB와 운영 API에 연결하지 않습니다. +- 실제 사용자·근로자 데이터나 Access·Refresh Token을 포함하지 않습니다. +- `Try it out`을 비활성화하고 외부 API 호출을 Content Security Policy로 막습니다. +- HTML에 표시하는 것은 API 경로, DTO Schema, 예시값과 비민감 build metadata뿐입니다. +- 배포 전에 생성기 테스트와 OpenAPI 기본 구조 검증을 통과해야 합니다. diff --git a/docs/database-documentation.md b/docs/database-documentation.md index e76c3b7..dc95e72 100644 --- a/docs/database-documentation.md +++ b/docs/database-documentation.md @@ -4,6 +4,7 @@ FOWOCO Server의 데이터베이스 문서는 `main`의 Flyway Migration을 일 PostgreSQL에 처음부터 적용한 결과로 생성합니다. - 팀 공유 사이트: +- API Swagger 문서: - 변경의 원본: `src/main/resources/db/migration` - 구조 결정의 원본: `docs/adr` diff --git a/scripts/api-docs/generate-site.mjs b/scripts/api-docs/generate-site.mjs new file mode 100644 index 0000000..8d4b3da --- /dev/null +++ b/scripts/api-docs/generate-site.mjs @@ -0,0 +1,199 @@ +import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +const SWAGGER_UI_VERSION = '5.32.2' + +function parseArguments(argv) { + const result = {} + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (!key?.startsWith('--') || value === undefined) { + throw new Error(`잘못된 인자입니다: ${key ?? '(없음)'}`) + } + result[key.slice(2)] = value + } + return result +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function safeEmbeddedJson(value) { + return JSON.stringify(value) + .replaceAll('&', '\\u0026') + .replaceAll('<', '\\u003c') + .replaceAll('>', '\\u003e') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029') +} + +function validateGeneratedAt(value) { + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + throw new Error(`생성 시각이 ISO-8601 형식이 아닙니다: ${value}`) + } + return parsed.toISOString() +} + +function validateOpenApi(specification) { + if (typeof specification !== 'object' || specification === null) { + throw new Error('OpenAPI 문서가 JSON 객체가 아닙니다.') + } + if (!String(specification.openapi ?? '').startsWith('3.')) { + throw new Error(`지원하지 않는 OpenAPI 버전입니다: ${specification.openapi ?? '(없음)'}`) + } + if (typeof specification.info?.title !== 'string' || specification.info.title.trim() === '') { + throw new Error('OpenAPI info.title이 없습니다.') + } + if (typeof specification.paths !== 'object' || specification.paths === null) { + throw new Error('OpenAPI paths가 없습니다.') + } +} + +function commitLink(repositoryUrl, commit) { + return /^[0-9a-f]{7,40}$/i.test(commit) + ? `${repositoryUrl}/commit/${commit}` + : repositoryUrl +} + +const swaggerInit = ` +window.addEventListener('DOMContentLoaded', () => { + const specification = JSON.parse(document.getElementById('openapi-specification').textContent) + window.ui = SwaggerUIBundle({ + spec: specification, + dom_id: '#swagger-ui', + deepLinking: true, + displayRequestDuration: true, + filter: true, + persistAuthorization: false, + supportedSubmitMethods: [], + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset, + ], + layout: 'BaseLayout', + }) +}) +`.trimStart() + +function page({ specification, generatedAt, commit, repositoryUrl }) { + const title = specification.info.title + const version = specification.info.version ?? 'unknown' + const commitHref = commitLink(repositoryUrl, commit) + + return ` + + + + + + + ${escapeHtml(title)} · Swagger + + + + +
+
+ FOWOCO Server API +
API ${escapeHtml(version)} · 생성 ${escapeHtml(generatedAt)} · commit ${escapeHtml(commit)}
+
+ +
+

+ main 코드에서 자동 생성한 읽기 전용 API 계약입니다. 실제 요청 전송은 비활성화되어 있습니다. + 로컬에서 API를 시험하려면 서버의 /swagger-ui.html을 사용하세요. +

+
+ + + + + +` +} + +async function main() { + const args = parseArguments(process.argv.slice(2)) + if (!args.openapi || !args.output) { + throw new Error('--openapi와 --output은 필수입니다.') + } + + const input = path.resolve(args.openapi) + const output = path.resolve(args.output) + const generatedAt = validateGeneratedAt(args['generated-at'] ?? new Date().toISOString()) + const commit = args.commit ?? 'unknown' + const repositoryUrl = args['repository-url'] ?? 'https://github.com/fowoco/server' + + await access(input) + const specification = JSON.parse(await readFile(input, 'utf8')) + validateOpenApi(specification) + delete specification.servers + + await mkdir(path.join(output, 'assets'), { recursive: true }) + await writeFile(path.join(output, 'index.html'), page({ + specification, + generatedAt, + commit, + repositoryUrl, + })) + await writeFile(path.join(output, 'openapi.json'), `${JSON.stringify(specification, null, 2)}\n`) + await writeFile(path.join(output, 'assets', 'swagger-init.js'), swaggerInit) + await writeFile(path.join(output, 'metadata.json'), `${JSON.stringify({ + generated_at: generatedAt, + git_commit: commit, + openapi_version: specification.openapi, + api_version: specification.info.version ?? null, + path_count: Object.keys(specification.paths).length, + swagger_ui_version: SWAGGER_UI_VERSION, + try_it_out_enabled: false, + }, null, 2)}\n`) +} + +main().catch((error) => { + console.error(`[api-docs] ${error.message}`) + process.exitCode = 1 +}) diff --git a/scripts/api-docs/generate-site.test.mjs b/scripts/api-docs/generate-site.test.mjs new file mode 100644 index 0000000..a999d11 --- /dev/null +++ b/scripts/api-docs/generate-site.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import test from 'node:test' + +const script = path.resolve('scripts/api-docs/generate-site.mjs') + +test('OpenAPI JSON을 읽기 전용 Swagger HTML로 변환한다', async () => { + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'fowoco-api-docs-')) + try { + const openApiFile = path.join(temporaryRoot, 'openapi.json') + const output = path.join(temporaryRoot, 'site') + await writeFile(openApiFile, JSON.stringify({ + openapi: '3.1.0', + info: { + title: 'FOWOCO API', + version: '0.1.0', + description: '', + }, + servers: [{ url: 'http://127.0.0.1:18080' }], + paths: { + '/health': { + get: { + summary: '서버 상태 확인', + responses: { 200: { description: '정상' } }, + }, + }, + }, + })) + + const result = spawnSync(process.execPath, [ + script, + '--openapi', openApiFile, + '--output', output, + '--commit', '1234567890abcdef1234567890abcdef12345678', + '--generated-at', '2026-07-24T01:02:03Z', + '--repository-url', 'https://github.com/fowoco/server', + ], { encoding: 'utf8' }) + + assert.equal(result.status, 0, result.stderr) + const index = await readFile(path.join(output, 'index.html'), 'utf8') + const init = await readFile(path.join(output, 'assets', 'swagger-init.js'), 'utf8') + const copiedSpecification = JSON.parse(await readFile(path.join(output, 'openapi.json'), 'utf8')) + const metadata = JSON.parse(await readFile(path.join(output, 'metadata.json'), 'utf8')) + + assert.match(index, /FOWOCO <Server> API/) + assert.match(index, /읽기 전용 API 계약/) + assert.doesNotMatch(index, /<\/script>') + assert.equal(copiedSpecification.servers, undefined) + assert.equal(metadata.path_count, 1) + assert.equal(metadata.try_it_out_enabled, false) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +}) + +test('유효한 OpenAPI 문서가 아니면 사이트 생성을 거부한다', async () => { + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'fowoco-api-docs-')) + try { + const openApiFile = path.join(temporaryRoot, 'openapi.json') + await writeFile(openApiFile, JSON.stringify({ + info: { title: 'broken' }, + paths: {}, + })) + + const result = spawnSync(process.execPath, [ + script, + '--openapi', openApiFile, + '--output', path.join(temporaryRoot, 'site'), + ], { encoding: 'utf8' }) + + assert.notEqual(result.status, 0) + assert.match(result.stderr, /\[api-docs\]/) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +}) diff --git a/scripts/api-docs/generate.sh b/scripts/api-docs/generate.sh new file mode 100755 index 0000000..7bb6ec9 --- /dev/null +++ b/scripts/api-docs/generate.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +OUTPUT_ROOT="${REPOSITORY_ROOT}/build/api-docs" +APPLICATION_PORT="${API_DOCS_PORT:-18080}" +APPLICATION_PID="" + +case "${OUTPUT_ROOT}" in + "${REPOSITORY_ROOT}/build/api-docs") ;; + *) + echo "[api-docs] 허용되지 않은 출력 경로입니다: ${OUTPUT_ROOT}" >&2 + exit 1 + ;; +esac + +for command in java node curl; do + if ! command -v "${command}" >/dev/null 2>&1; then + echo "[api-docs] ${command} 명령을 찾지 못했습니다." >&2 + exit 1 + fi +done + +if curl --silent --fail "http://127.0.0.1:${APPLICATION_PORT}/health" >/dev/null 2>&1; then + echo "[api-docs] ${APPLICATION_PORT} 포트가 이미 사용 중입니다. API_DOCS_PORT를 변경해 주세요." >&2 + exit 1 +fi + +cleanup() { + if [[ -n "${APPLICATION_PID}" ]] && kill -0 "${APPLICATION_PID}" >/dev/null 2>&1; then + kill "${APPLICATION_PID}" >/dev/null 2>&1 || true + wait "${APPLICATION_PID}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT INT TERM + +rm -rf "${OUTPUT_ROOT}" +mkdir -p "${OUTPUT_ROOT}/site" + +echo "[api-docs] 실행 가능한 서버 jar를 빌드합니다." +"${REPOSITORY_ROOT}/gradlew" -p "${REPOSITORY_ROOT}" bootJar + +APPLICATION_JAR="$(find "${REPOSITORY_ROOT}/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' -print -quit)" +if [[ -z "${APPLICATION_JAR}" ]]; then + echo "[api-docs] 실행 가능한 서버 jar를 찾지 못했습니다." >&2 + exit 1 +fi + +echo "[api-docs] 실제 Spring MVC 계약을 test profile로 생성합니다." +java -jar "${APPLICATION_JAR}" \ + --spring.profiles.active=test \ + --server.address=127.0.0.1 \ + --server.port="${APPLICATION_PORT}" \ + > "${OUTPUT_ROOT}/application.log" 2>&1 & +APPLICATION_PID="$!" + +OPENAPI_TEMP="${OUTPUT_ROOT}/openapi.json.tmp" +for attempt in $(seq 1 60); do + if curl --silent --fail \ + "http://127.0.0.1:${APPLICATION_PORT}/v3/api-docs" \ + --output "${OPENAPI_TEMP}"; then + break + fi + if ! kill -0 "${APPLICATION_PID}" >/dev/null 2>&1; then + echo "[api-docs] 서버가 준비되기 전에 종료되었습니다." >&2 + tail -n 100 "${OUTPUT_ROOT}/application.log" >&2 + exit 1 + fi + if [[ "${attempt}" == "60" ]]; then + echo "[api-docs] OpenAPI endpoint가 준비되지 않았습니다." >&2 + tail -n 100 "${OUTPUT_ROOT}/application.log" >&2 + exit 1 + fi + sleep 1 +done +mv "${OPENAPI_TEMP}" "${OUTPUT_ROOT}/openapi.json" + +GIT_COMMIT="${API_DOCS_GIT_COMMIT:-${GITHUB_SHA:-$(git -C "${REPOSITORY_ROOT}" rev-parse HEAD)}}" +if [[ -n "${GITHUB_SERVER_URL:-}" && -n "${GITHUB_REPOSITORY:-}" ]]; then + REPOSITORY_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" +else + REPOSITORY_URL="https://github.com/fowoco/server" +fi + +node "${SCRIPT_DIR}/generate-site.mjs" \ + --openapi "${OUTPUT_ROOT}/openapi.json" \ + --output "${OUTPUT_ROOT}/site" \ + --commit "${GIT_COMMIT}" \ + --generated-at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --repository-url "${REPOSITORY_URL}" + +echo "[api-docs] 생성 완료: ${OUTPUT_ROOT}/site/index.html" diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerCreateRequest.java b/src/main/java/com/fowoco/server/worker/api/WorkerCreateRequest.java index 1cad3a0..a84cda2 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerCreateRequest.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerCreateRequest.java @@ -15,6 +15,7 @@ public final class WorkerCreateRequest { @Schema( + name = "display_name", description = "화면 표시용 근로자 이름", example = "응웬반A", maxLength = 120, @@ -25,6 +26,7 @@ public final class WorkerCreateRequest { private final String displayName; @Schema( + name = "nationality_code", description = "국적 코드", example = "VN", maxLength = 10 @@ -33,6 +35,7 @@ public final class WorkerCreateRequest { private final String nationalityCode; @Schema( + name = "preferred_language", description = "선호 언어", example = "vi", maxLength = 20 @@ -41,6 +44,7 @@ public final class WorkerCreateRequest { private final String preferredLanguage; @Schema( + name = "stay_expiry_date", description = "체류 만료일", example = "2027-03-01", format = "date" @@ -48,6 +52,7 @@ public final class WorkerCreateRequest { private final LocalDate stayExpiryDate; @Schema( + name = "contract_start_date", description = "계약 시작일", example = "2026-01-01", format = "date" @@ -55,6 +60,7 @@ public final class WorkerCreateRequest { private final LocalDate contractStartDate; @Schema( + name = "contract_end_date", description = "계약 종료일. contract_start_date보다 빠를 수 없습니다.", example = "2027-12-31", format = "date" diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentCreateRequest.java b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentCreateRequest.java index 4a3bab8..169dd63 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentCreateRequest.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentCreateRequest.java @@ -16,6 +16,7 @@ public final class WorkerDocumentCreateRequest { @Schema( + name = "document_type", description = "서류 유형", allowableValues = {"PASSPORT_COPY", "ARC", "CONTRACT", "PERMIT"}, requiredMode = Schema.RequiredMode.REQUIRED @@ -24,6 +25,7 @@ public final class WorkerDocumentCreateRequest { private final DocumentType documentType; @Schema( + name = "submission_status", description = "제출 상태", allowableValues = {"MISSING", "SUBMITTED", "VERIFIED"}, requiredMode = Schema.RequiredMode.REQUIRED @@ -31,7 +33,7 @@ public final class WorkerDocumentCreateRequest { @NotNull(message = "submission_status를 입력해 주세요.") private final SubmissionStatus submissionStatus; - @Schema(description = "서류 유효기간", example = "2027-03-01", format = "date") + @Schema(name = "expiry_date", description = "서류 유효기간", example = "2027-03-01", format = "date") private final LocalDate expiryDate; @Schema(description = "제출처", example = "출입국관리사무소", maxLength = 120) diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java index ca65dd1..5e6fffd 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java @@ -15,13 +15,13 @@ ) public final class WorkerDocumentPatchRequest { - @Schema(description = "서류 유형. 생략 시 변경하지 않습니다.") + @Schema(name = "document_type", description = "서류 유형. 생략 시 변경하지 않습니다.") private final DocumentType documentType; - @Schema(description = "제출 상태. 생략 시 변경하지 않습니다.") + @Schema(name = "submission_status", description = "제출 상태. 생략 시 변경하지 않습니다.") private final SubmissionStatus submissionStatus; - @Schema(description = "서류 유효기간. 생략 시 변경하지 않습니다.", format = "date") + @Schema(name = "expiry_date", description = "서류 유효기간. 생략 시 변경하지 않습니다.", format = "date") private final LocalDate expiryDate; @Schema(description = "제출처. 생략 시 변경하지 않습니다.", maxLength = 120) @@ -33,6 +33,7 @@ public final class WorkerDocumentPatchRequest { private final String note; @Schema( + name = "expected_version", description = "낙관적 잠금 버전. 마지막으로 조회한 WorkerDocumentResponse.version을 그대로 보내야 합니다.", requiredMode = Schema.RequiredMode.REQUIRED ) diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerPatchRequest.java b/src/main/java/com/fowoco/server/worker/api/WorkerPatchRequest.java index 9af5a49..25bf1a8 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerPatchRequest.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerPatchRequest.java @@ -16,6 +16,7 @@ public final class WorkerPatchRequest { @Schema( + name = "display_name", description = "화면 표시용 근로자 이름. 생략 시 변경하지 않습니다.", example = "응우옌반A", maxLength = 120 @@ -24,6 +25,7 @@ public final class WorkerPatchRequest { private final String displayName; @Schema( + name = "nationality_code", description = "국적 코드. 생략 시 변경하지 않습니다.", example = "VN", maxLength = 10 @@ -32,6 +34,7 @@ public final class WorkerPatchRequest { private final String nationalityCode; @Schema( + name = "preferred_language", description = "선호 언어. 생략 시 변경하지 않습니다.", example = "vi", maxLength = 20 @@ -40,11 +43,13 @@ public final class WorkerPatchRequest { private final String preferredLanguage; @Schema( + name = "work_status", description = "근무 상태. 생략 시 변경하지 않습니다." ) private final WorkerStatus workStatus; @Schema( + name = "stay_expiry_date", description = "체류 만료일. 생략 시 변경하지 않습니다.", example = "2027-03-01", format = "date" @@ -52,6 +57,7 @@ public final class WorkerPatchRequest { private final LocalDate stayExpiryDate; @Schema( + name = "contract_start_date", description = "계약 시작일. 생략 시 변경하지 않습니다.", example = "2026-01-01", format = "date" @@ -59,6 +65,7 @@ public final class WorkerPatchRequest { private final LocalDate contractStartDate; @Schema( + name = "contract_end_date", description = "계약 종료일. 생략 시 변경하지 않습니다.", example = "2027-12-31", format = "date" @@ -66,6 +73,7 @@ public final class WorkerPatchRequest { private final LocalDate contractEndDate; @Schema( + name = "expected_version", description = "낙관적 잠금 버전. 마지막으로 조회한 WorkerResponse.version을 그대로 보내야 합니다.", example = "0", minimum = "0", diff --git a/src/test/java/com/fowoco/server/ServerApplicationTests.java b/src/test/java/com/fowoco/server/ServerApplicationTests.java index 3b24a43..290656c 100644 --- a/src/test/java/com/fowoco/server/ServerApplicationTests.java +++ b/src/test/java/com/fowoco/server/ServerApplicationTests.java @@ -7,6 +7,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.Map; import org.flywaydb.core.Flyway; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -56,6 +57,40 @@ void swaggerUiIsPublic() throws Exception { assertThat(response.body()).contains("Swagger UI"); } + @Test + void openApiRequestSchemasUseCanonicalSnakeCaseProperties() throws Exception { + HttpResponse response = get("/v3/api-docs"); + Map workerCreateProperties = JsonPath.read( + response.body(), + "$.components.schemas.WorkerCreateRequest.properties" + ); + Map workerPatchProperties = JsonPath.read( + response.body(), + "$.components.schemas.WorkerPatchRequest.properties" + ); + Map documentCreateProperties = JsonPath.read( + response.body(), + "$.components.schemas.WorkerDocumentCreateRequest.properties" + ); + Map documentPatchProperties = JsonPath.read( + response.body(), + "$.components.schemas.WorkerDocumentPatchRequest.properties" + ); + + assertThat(workerCreateProperties.keySet()) + .contains("display_name", "nationality_code", "preferred_language") + .noneMatch(property -> property.matches(".*[A-Z].*")); + assertThat(workerPatchProperties.keySet()) + .contains("work_status", "stay_expiry_date", "expected_version") + .noneMatch(property -> property.matches(".*[A-Z].*")); + assertThat(documentCreateProperties.keySet()) + .contains("document_type", "submission_status", "expiry_date") + .noneMatch(property -> property.matches(".*[A-Z].*")); + assertThat(documentPatchProperties.keySet()) + .contains("document_type", "submission_status", "expected_version") + .noneMatch(property -> property.matches(".*[A-Z].*")); + } + @Test void reactDevelopmentOriginCanSendPreflightRequest() throws Exception { HttpRequest request = HttpRequest.newBuilder()