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

52
src/utils/error.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Error handling utilities for type-safe error conversion
*
* @module error
* @example
* ```typescript
* import { toError } from './utils/error';
*
* try {
* // ...
* } catch (error) {
* logger.error('Operation failed', toError(error));
* }
* ```
*/
/**
* unknown 타입을 Error로 안전하게 변환
*
* catch 블록에서 unknown 타입의 error를 Error 객체로 변환합니다.
* TypeScript strict mode에서 `error as Error` 타입 단언을 피하기 위해 사용합니다.
*
* @param error - catch 블록에서 받은 unknown 타입 에러
* @returns Error 객체
*
* @example
* ```typescript
* try {
* await riskyOperation();
* } catch (error) {
* // ✅ Type-safe
* logger.error('Operation failed', toError(error));
*
* // ❌ Type assertion (avoid)
* // logger.error('Operation failed', error as Error);
* }
* ```
*/
export function toError(error: unknown): Error {
// Already an Error object - return as-is
if (error instanceof Error) {
return error;
}
// String error - wrap in Error object
if (typeof error === 'string') {
return new Error(error);
}
// Other types - convert to string and wrap
return new Error(String(error));
}