diff --git a/bun.lock b/bun.lock index 7000467..bbfdde7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,13 @@ "": { "name": "boj-jury-backend", "dependencies": { + "@hono/swagger-ui": "^0.6.1", "hono": "^4.12.9", }, "devDependencies": { "@cloudflare/workers-types": "^4.20250101.0", "bun-types": "latest", - "typescript": "^6.0.0", + "typescript": "^6.0.2", "wrangler": "^3.101.0", }, }, @@ -86,6 +87,8 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@hono/swagger-ui": ["@hono/swagger-ui@0.6.1", "", { "peerDependencies": { "hono": ">=4.0.0" } }, "sha512-sJTvldu1GPeEPfyeLG7gRj+W4vEuD+JDi+JjJ3TJs/DvMUtBLs0KJO5yokGegWWdy5qrbdnQGekbhgNRmPmYKQ=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], diff --git a/package.json b/package.json index ffd4810..2670587 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "wrangler": "^3.101.0" }, "dependencies": { + "@hono/swagger-ui": "^0.6.1", "hono": "^4.12.9" } } diff --git a/src/index.ts b/src/index.ts index 81daa84..7c71267 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; +import { swaggerUI } from '@hono/swagger-ui'; import { Env } from './config'; import { AppError } from './errors'; import { handleDiscordRequest } from './discord/interactions'; import { injectContext } from './middleware/context'; +import { openApiSpec } from './openapi'; import { AppVariables } from './types'; import adminRouter from './routes/admin'; import leaderboardRouter from './routes/leaderboard'; @@ -16,6 +18,7 @@ const app = new Hono<{ Bindings: Env; Variables: AppVariables }>(); const ALLOWED_ORIGINS = [ 'http://localhost:3000', 'https://gameworks-v2.pages.dev', + 'https://boj-jury.gameworks.workers.dev', ]; // ── 미들웨어 ────────────────────────────────────────────────────────────── @@ -53,6 +56,9 @@ app.onError((err, c) => { // ── 라우터 등록 ─────────────────────────────────────────────────────────── +app.get('/docs', swaggerUI({ url: '/docs/spec' })); +app.get('/docs/spec', c => c.json(openApiSpec)); + app.post('/discord/interactions', c => handleDiscordRequest(c.req.raw, c.get('ctx'), c.executionCtx)); app.route('/leaderboard', leaderboardRouter); diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 0000000..4846ca6 --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,430 @@ +export const openApiSpec = { + openapi: '3.0.3', + info: { + title: 'BOJ Jury API', + version: '1.0.0', + description: 'BOJ 풀이 기반 리그 대회 관리 API', + }, + servers: [{ url: '/' }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'ADMIN_TOKEN (wrangler secret)', + }, + }, + schemas: { + League: { type: 'string', enum: ['ROOKIE', 'JUNIOR', 'PRO'] }, + Error: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + Message: { + type: 'object', + properties: { message: { type: 'string' } }, + }, + }, + }, + paths: { + // ── 참가자 등록 ──────────────────────────────────────────────────────── + '/register': { + post: { + tags: ['Registration'], + summary: '참가자 등록', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['real_name', 'boj_handle'], + properties: { + real_name: { type: 'string', example: '홍길동' }, + boj_handle: { type: 'string', example: 'gildong' }, + }, + }, + }, + }, + }, + responses: { + 201: { + description: '등록 성공', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + real_name: { type: 'string' }, + boj_handle: { type: 'string' }, + league: { $ref: '#/components/schemas/League' }, + registered_at: { type: 'string', format: 'date-time', example: '2025-03-01T12:00:00Z' }, + }, + }, + }, + }, + }, + 400: { description: '잘못된 요청 (핸들 없음, 조직 미인증 등)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/register/status': { + get: { + tags: ['Registration'], + summary: '등록 현황 조회', + parameters: [ + { name: 'boj_handle', in: 'query', required: true, schema: { type: 'string' } }, + ], + responses: { + 200: { + description: '조회 성공', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + contest_name: { type: 'string' }, + real_name: { type: 'string' }, + boj_handle: { type: 'string' }, + league: { $ref: '#/components/schemas/League' }, + registered_at: { type: 'string', format: 'date-time' }, + is_disqualified: { type: 'boolean' }, + }, + }, + }, + }, + }, + 400: { description: '쿼리 파라미터 누락', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '참가자 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + + // ── 리더보드 ────────────────────────────────────────────────────────── + '/leaderboard': { + get: { + tags: ['Leaderboard'], + summary: '리더보드 조회', + parameters: [ + { name: 'league', in: 'query', required: false, schema: { $ref: '#/components/schemas/League' } }, + ], + responses: { + 200: { + description: '조회 성공', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + results: { + type: 'array', + items: { + type: 'object', + properties: { + contest_name: { type: 'string' }, + league: { $ref: '#/components/schemas/League' }, + generated_at: { type: 'string', format: 'date-time' }, + rows: { + type: 'array', + items: { + type: 'object', + properties: { + rank: { type: 'integer' }, + boj_handle: { type: 'string' }, + real_name: { type: 'string' }, + total_score: { type: 'integer' }, + solved_count: { type: 'integer' }, + last_solved_at: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + + // ── 관리자 — 대회 ───────────────────────────────────────────────────── + '/admin/contests': { + post: { + tags: ['Admin / Contest'], + summary: '대회 생성', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name', 'start_at', 'end_at'], + properties: { + name: { type: 'string', example: '2025 Spring League' }, + start_at: { type: 'string', format: 'date', example: '2025-03-01' }, + end_at: { type: 'string', format: 'date', example: '2025-05-31' }, + leaderboard_post_time: { type: 'string', format: 'time', example: '21:00', default: '21:00' }, + }, + }, + }, + }, + }, + responses: { + 201: { description: '생성 성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 400: { description: '잘못된 요청', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/contests/name': { + patch: { + tags: ['Admin / Contest'], + summary: '대회 이름 변경', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', required: ['new_name'], properties: { new_name: { type: 'string' } } } } }, + }, + responses: { + 200: { + description: '변경 성공', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + old_name: { type: 'string' }, + new_name: { type: 'string' }, + }, + }, + }, + }, + }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '활성 대회 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/contests/period': { + patch: { + tags: ['Admin / Contest'], + summary: '대회 기간 변경', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', required: ['start_at', 'end_at'], properties: { start_at: { type: 'string', format: 'date' }, end_at: { type: 'string', format: 'date' } } } } }, + }, + responses: { + 200: { description: '변경 성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '활성 대회 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/contests/leaderboard-time': { + patch: { + tags: ['Admin / Contest'], + summary: '리더보드 게시 시각 변경', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', required: ['time'], properties: { time: { type: 'string', format: 'time', example: '21:00' } } } } }, + }, + responses: { + 200: { description: '변경 성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '활성 대회 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/contests/pause': { + post: { + tags: ['Admin / Contest'], + summary: '대회 일시정지', + security: [{ bearerAuth: [] }], + responses: { + 200: { description: '성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '활성 대회 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/contests/resume': { + post: { + tags: ['Admin / Contest'], + summary: '대회 재개', + security: [{ bearerAuth: [] }], + responses: { + 200: { description: '성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '재개할 대회 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/contests/archive': { + post: { + tags: ['Admin / Contest'], + summary: '대회 보관', + security: [{ bearerAuth: [] }], + responses: { + 200: { description: '성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '활성 대회 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + + '/admin/leaderboard/refresh': { + post: { + tags: ['Admin / Leaderboard'], + summary: '스냅샷 수동 갱신', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: '갱신 성공 (활성 대회 없을 경우 message만 반환)', + content: { + 'application/json': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + contest_name: { type: 'string' }, + snapshots_added: { type: 'integer' }, + }, + }, + { $ref: '#/components/schemas/Message' }, + ], + }, + }, + }, + }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + + // ── 관리자 — 참가자 ─────────────────────────────────────────────────── + '/admin/users': { + get: { + tags: ['Admin / Users'], + summary: '참가자 목록 조회', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'league', in: 'query', required: false, schema: { $ref: '#/components/schemas/League' } }, + ], + responses: { + 200: { + description: '조회 성공', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + participants: { + type: 'array', + items: { + type: 'object', + properties: { + boj_handle: { type: 'string' }, + real_name: { type: 'string' }, + league: { $ref: '#/components/schemas/League' }, + is_disqualified: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, + }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/users/{handle}': { + get: { + tags: ['Admin / Users'], + summary: '참가자 상세 조회', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'handle', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { + description: '조회 성공', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + contest_name: { type: 'string' }, + real_name: { type: 'string' }, + boj_handle: { type: 'string' }, + league: { $ref: '#/components/schemas/League' }, + tier_at_registration: { type: 'integer' }, + total_score: { type: 'integer' }, + solved_count: { type: 'integer' }, + last_solved_at: { type: 'string', format: 'date-time', nullable: true }, + is_disqualified: { type: 'boolean' }, + }, + }, + }, + }, + }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '참가자 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/users/{handle}/disqualify': { + post: { + tags: ['Admin / Users'], + summary: '참가자 실격 처리', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'handle', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { description: '성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '참가자 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/users/{handle}/name': { + patch: { + tags: ['Admin / Users'], + summary: '참가자 실명 수정', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'handle', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', required: ['new_name'], properties: { new_name: { type: 'string' } } } } }, + }, + responses: { + 200: { description: '수정 성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '참가자 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + '/admin/users/{handle}/league': { + patch: { + tags: ['Admin / Users'], + summary: '참가자 리그 수정', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'handle', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', required: ['league'], properties: { league: { $ref: '#/components/schemas/League' } } } } }, + }, + responses: { + 200: { description: '수정 성공', content: { 'application/json': { schema: { $ref: '#/components/schemas/Message' } } } }, + 401: { description: '인증 실패', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + 404: { description: '참가자 없음', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }, + }, + }, + }, + }, +}; diff --git a/src/routes/registration.ts b/src/routes/registration.ts index e60e57c..fb9c534 100644 --- a/src/routes/registration.ts +++ b/src/routes/registration.ts @@ -30,7 +30,7 @@ router.get('/status', async c => { const status = await c.get('ctx').registrationSvc.getParticipantStatus(bojHandle); if (!status) { - return c.json({ message: '등록된 참가자를 찾을 수 없습니다.' }, 404); + return c.json({ error: '등록된 참가자를 찾을 수 없습니다.' }, 404); } return c.json({ diff --git a/tsconfig.json b/tsconfig.json index 6a321b8..abf636f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "lib": ["ES2022"], "types": ["@cloudflare/workers-types"], "strict": true, + "skipLibCheck": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "outDir": "./dist",