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:
@@ -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
52
src/utils/error.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user