Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions .github/workflows/database-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ API 문서는 아래 주소에서 확인합니다.

- Swagger UI: <http://localhost:8080/swagger-ui.html>
- OpenAPI JSON: <http://localhost:8080/v3/api-docs>
- 팀 공유용 Swagger HTML: <https://fowoco.github.io/server/api/>
- 공유 문서 사용법: [API 문서 사용법](docs/api-documentation.md)
- H2 Console(local 전용): <http://localhost:8080/h2-console>

local은 기본 Profile이라 별도 데이터베이스가 필요하지 않습니다. 서버를 다시 실행하면 메모리 DB가 초기화되고 Flyway migration이 처음부터 적용됩니다. H2 Console 보호를 위해 local 서버는 기본적으로 내 PC(`127.0.0.1`)에서만 접근할 수 있습니다.
Expand Down
69 changes: 69 additions & 0 deletions docs/api-documentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# API 문서 사용법

FOWOCO Server의 공유용 Swagger HTML은 `main`에서 실제로 생성되는 OpenAPI
JSON을 읽기 쉬운 화면으로 변환한 문서입니다.

- 팀 공유 사이트: <https://fowoco.github.io/server/api/>
- OpenAPI JSON: <https://fowoco.github.io/server/api/openapi.json>
- 로컬 Swagger UI: <http://localhost:8080/swagger-ui.html>

## 어떤 문서인가요?

| 문서 | 용도 |
| --- | --- |
| 공유용 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 기본 구조 검증을 통과해야 합니다.
1 change: 1 addition & 0 deletions docs/database-documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ FOWOCO Server의 데이터베이스 문서는 `main`의 Flyway Migration을 일
PostgreSQL에 처음부터 적용한 결과로 생성합니다.

- 팀 공유 사이트: <https://fowoco.github.io/server/>
- API Swagger 문서: <https://fowoco.github.io/server/api/>
- 변경의 원본: `src/main/resources/db/migration`
- 구조 결정의 원본: `docs/adr`

Expand Down
199 changes: 199 additions & 0 deletions scripts/api-docs/generate-site.mjs
Original file line number Diff line number Diff line change
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}

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 `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="FOWOCO Server main 브랜치에서 자동 생성한 OpenAPI 문서">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'">
<title>${escapeHtml(title)} · Swagger</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css">
<style>
:root { color-scheme: light; --brand: #0c6a65; --brand-dark: #084d49; }
* { box-sizing: border-box; }
body { margin: 0; background: #fafafa; }
.api-docs-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem clamp(1rem, 4vw, 3rem);
color: #fff;
background: var(--brand-dark);
font-family: Inter, Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.api-docs-header strong { font-size: 1.05rem; }
.api-docs-header nav { display: flex; flex-wrap: wrap; gap: 1rem; }
.api-docs-header a { color: #fff; }
.api-docs-notice {
margin: 1rem auto 0;
width: min(1280px, calc(100% - 2rem));
padding: .85rem 1rem;
border-left: 4px solid var(--brand);
background: #e9f5f3;
font: 14px/1.6 Inter, Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.api-docs-meta { color: #d5e8e6; font-size: .85rem; }
.swagger-ui .topbar,
.swagger-ui .auth-wrapper { display: none; }
@media (max-width: 720px) {
.api-docs-header { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<header class="api-docs-header">
<div>
<strong>FOWOCO Server API</strong>
<div class="api-docs-meta">API ${escapeHtml(version)} · 생성 ${escapeHtml(generatedAt)} · <a href="${escapeHtml(commitHref)}">commit ${escapeHtml(commit)}</a></div>
</div>
<nav aria-label="관련 문서">
<a href="../index.html">Database 문서</a>
<a href="openapi.json" download>OpenAPI JSON</a>
<a href="${escapeHtml(repositoryUrl)}">GitHub</a>
</nav>
</header>
<p class="api-docs-notice">
main 코드에서 자동 생성한 읽기 전용 API 계약입니다. 실제 요청 전송은 비활성화되어 있습니다.
로컬에서 API를 시험하려면 서버의 <code>/swagger-ui.html</code>을 사용하세요.
</p>
<main id="swagger-ui"></main>
<script id="openapi-specification" type="application/json">${safeEmbeddedJson(specification)}</script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-standalone-preset.js"></script>
<script src="assets/swagger-init.js"></script>
</body>
</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
})
Loading
Loading