Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
13 changes: 12 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250101.0",
"@types/swagger-ui-dist": "^3.30.6",
"bun-types": "latest",
"typescript": "^6.0.2",
"wrangler": "^3.101.0"
},
"dependencies": {
"hono": "^4.12.9"
"@hono/swagger-ui": "^0.6.1",
"hono": "^4.12.9",
"swagger-ui-dist": "^5.32.1"
}
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -53,6 +55,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);
Expand Down
283 changes: 283 additions & 0 deletions src/openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
export const openApiSpec = {
openapi: '3.0.3',
info: {
title: 'BOJ Jury API',
version: '1.0.0',
description: 'BOJ 풀이 기반 리그 대회 관리 API',
},
servers: [{ url: 'https://boj-jury.gameworks.workers.dev' }],
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' } },
},
},
},
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' },
},
},
},
},
},
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' },
is_disqualified: { type: 'boolean' },
},
},
},
},
},
404: { description: '참가자 없음' },
},
},
},

// ── 리더보드 ──────────────────────────────────────────────────────────
'/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' },
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', 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', example: '2025-03-01' },
end_at: { type: 'string', example: '2025-05-31' },
leaderboard_post_time: { type: 'string', example: '21:00', default: '21:00' },
},
},
},
},
},
responses: {
201: { description: '생성 성공' },
401: { description: '인증 실패' },
},
},
},
'/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: '변경 성공' }, 401: { description: '인증 실패' } },
},
},
'/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' }, end_at: { type: 'string' } } } } },
},
responses: { 200: { description: '변경 성공' }, 401: { description: '인증 실패' } },
},
},
'/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', example: '21:00' } } } } },
},
responses: { 200: { description: '변경 성공' }, 401: { description: '인증 실패' } },
},
},
'/admin/contests/pause': { post: { tags: ['Admin / Contest'], summary: '대회 일시정지', security: [{ bearerAuth: [] }], responses: { 200: { description: '성공' } } } },
'/admin/contests/resume': { post: { tags: ['Admin / Contest'], summary: '대회 재개', security: [{ bearerAuth: [] }], responses: { 200: { description: '성공' } } } },
'/admin/contests/archive': { post: { tags: ['Admin / Contest'], summary: '대회 보관', security: [{ bearerAuth: [] }], responses: { 200: { description: '성공' } } } },

'/admin/leaderboard/refresh': {
post: {
tags: ['Admin / Leaderboard'],
summary: '스냅샷 수동 갱신',
security: [{ bearerAuth: [] }],
responses: { 200: { description: '갱신 성공' }, 401: { description: '인증 실패' } },
},
},

// ── 관리자 — 참가자 ───────────────────────────────────────────────────
'/admin/users': {
get: {
tags: ['Admin / Users'],
summary: '참가자 목록 조회',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'league', in: 'query', required: false, schema: { $ref: '#/components/schemas/League' } },
],
responses: { 200: { description: '조회 성공' }, 401: { description: '인증 실패' } },
},
},
'/admin/users/{handle}': {
get: {
tags: ['Admin / Users'],
summary: '참가자 상세 조회',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'handle', in: 'path', required: true, schema: { type: 'string' } }],
responses: { 200: { description: '조회 성공' }, 404: { description: '참가자 없음' } },
},
},
'/admin/users/{handle}/disqualify': {
post: {
tags: ['Admin / Users'],
summary: '참가자 실격 처리',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'handle', in: 'path', required: true, schema: { type: 'string' } }],
responses: { 200: { description: '성공' }, 404: { description: '참가자 없음' } },
},
},
'/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: '수정 성공' } },
},
},
'/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: '수정 성공' } },
},
},
},
};
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"outDir": "./dist",
Expand Down
Loading