refactor: DRY improvements and type-safe error handling

DRY Improvements (api.ts):
- Extract requireApiKey() helper for API authentication
- Extract getCorsHeaders() helper for CORS header generation
- Eliminate ~20 lines of duplicated code

Type Safety (new utils/error.ts):
- Add toError() utility for safe error type conversion
- Replace all 6 `error as Error` assertions with toError()
- Proper handling of Error, string, and unknown types

Error Handling (api.ts):
- Add explicit JSON parsing error handling to all POST endpoints
- Return 400 Bad Request for malformed JSON
- Clearer error messages for API consumers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-20 00:16:33 +09:00
parent 160ba5f427
commit a84b7314b4
2 changed files with 118 additions and 31 deletions

View File

@@ -9,6 +9,7 @@ import {
import { handleCommand } from '../commands';
import { openaiCircuitBreaker } from '../openai-service';
import { createLogger } from '../utils/logger';
import { toError } from '../utils/error';
const logger = createLogger('api');
@@ -32,6 +33,31 @@ const ContactFormBodySchema = z.object({
name: z.string().optional(),
});
/**
* API Key 인증 검증
* @returns 인증 실패 시 Response, 성공 시 null
*/
function requireApiKey(request: Request, env: Env): Response | null {
const apiSecret = env.DEPOSIT_API_SECRET;
const authHeader = request.headers.get('X-API-Key');
if (!apiSecret || authHeader !== apiSecret) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
return null;
}
/**
* CORS 헤더 생성
*/
function getCorsHeaders(env: Env): Record<string, string> {
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
return {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
}
// 사용자 조회/생성
async function getOrCreateUser(
db: D1Database,
@@ -72,12 +98,9 @@ async function getOrCreateUser(
*/
async function handleDepositBalance(request: Request, env: Env, url: URL): Promise<Response> {
try {
const apiSecret = env.DEPOSIT_API_SECRET;
const authHeader = request.headers.get('X-API-Key');
if (!apiSecret || authHeader !== apiSecret) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// API Key 인증
const authError = requireApiKey(request, env);
if (authError) return authError;
const telegramId = url.searchParams.get('telegram_id');
if (!telegramId) {
@@ -103,7 +126,7 @@ async function handleDepositBalance(request: Request, env: Env, url: URL): Promi
balance: deposit?.balance || 0,
});
} catch (error) {
logger.error('Deposit balance error', error as Error);
logger.error('Deposit balance error', toError(error));
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -117,14 +140,18 @@ async function handleDepositBalance(request: Request, env: Env, url: URL): Promi
*/
async function handleDepositDeduct(request: Request, env: Env): Promise<Response> {
try {
const apiSecret = env.DEPOSIT_API_SECRET;
const authHeader = request.headers.get('X-API-Key');
// API Key 인증
const authError = requireApiKey(request, env);
if (authError) return authError;
if (!apiSecret || authHeader !== apiSecret) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
// JSON 파싱 (별도 에러 핸들링)
let jsonData: unknown;
try {
jsonData = await request.json();
} catch {
return Response.json({ error: 'Invalid JSON format' }, { status: 400 });
}
const jsonData = await request.json();
const parseResult = DepositDeductBodySchema.safeParse(jsonData);
if (!parseResult.success) {
@@ -203,7 +230,7 @@ async function handleDepositDeduct(request: Request, env: Env): Promise<Response
context: 'api_deposit_deduct_rollback'
});
} catch (rollbackError) {
logger.error('잔액 복구 실패 - 수동 확인 필요', rollbackError as Error, {
logger.error('잔액 복구 실패 - 수동 확인 필요', toError(rollbackError), {
userId: user.id,
telegram_id: body.telegram_id,
amount: body.amount,
@@ -234,7 +261,7 @@ async function handleDepositDeduct(request: Request, env: Env): Promise<Response
new_balance: newBalance,
});
} catch (error) {
logger.error('Deposit deduct error', error as Error);
logger.error('Deposit deduct error', toError(error));
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -248,7 +275,14 @@ async function handleDepositDeduct(request: Request, env: Env): Promise<Response
*/
async function handleTestApi(request: Request, env: Env): Promise<Response> {
try {
const jsonData = await request.json();
// JSON 파싱 (별도 에러 핸들링)
let jsonData: unknown;
try {
jsonData = await request.json();
} catch {
return Response.json({ error: 'Invalid JSON format' }, { status: 400 });
}
const parseResult = TestApiBodySchema.safeParse(jsonData);
if (!parseResult.success) {
@@ -309,7 +343,7 @@ async function handleTestApi(request: Request, env: Env): Promise<Response> {
user_id: telegramUserId,
});
} catch (error) {
logger.error('Test API error', error as Error);
logger.error('Test API error', toError(error));
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -322,13 +356,9 @@ async function handleTestApi(request: Request, env: Env): Promise<Response> {
* @returns JSON response with success status
*/
async function handleContactForm(request: Request, env: Env): Promise<Response> {
// CORS: hosting.anvil.it.com만 허용
// CORS 헤더 생성
const corsHeaders = getCorsHeaders(env);
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
const corsHeaders = {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
// Origin 헤더 검증 (curl 우회 방지)
const origin = request.headers.get('Origin');
@@ -342,7 +372,17 @@ async function handleContactForm(request: Request, env: Env): Promise<Response>
}
try {
const jsonData = await request.json();
// JSON 파싱 (별도 에러 핸들링)
let jsonData: unknown;
try {
jsonData = await request.json();
} catch {
return Response.json(
{ error: 'Invalid JSON format' },
{ status: 400, headers: corsHeaders }
);
}
const parseResult = ContactFormBodySchema.safeParse(jsonData);
if (!parseResult.success) {
@@ -387,7 +427,7 @@ async function handleContactForm(request: Request, env: Env): Promise<Response>
{ headers: corsHeaders }
);
} catch (error) {
logger.error('Contact form error', error as Error);
logger.error('Contact form error', toError(error));
return Response.json(
{ error: '문의 전송 중 오류가 발생했습니다.' },
{ status: 500, headers: corsHeaders }
@@ -402,13 +442,8 @@ async function handleContactForm(request: Request, env: Env): Promise<Response>
* @returns Response with CORS headers
*/
async function handleContactPreflight(env: Env): Promise<Response> {
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
headers: getCorsHeaders(env),
});
}
@@ -468,7 +503,7 @@ async function handleMetrics(request: Request, env: Env): Promise<Response> {
return Response.json(metrics);
} catch (error) {
logger.error('Metrics API error', error as Error);
logger.error('Metrics API error', toError(error));
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}