Security fixes: - migrate.ts: SQL/Command Injection 방지 (spawnSync 사용) - migrate.ts: Path Traversal 검증 추가 - api-tester.ts: API 키 마스킹 (4자만 노출) - api-tester.ts: 최소 16자 키 길이 검증 - cache.ts: ReDoS 방지 (패턴 길이/와일드카드 제한) Performance improvements: - cache.ts: 순차 삭제 → 병렬 배치 처리 (50개씩) - cache.ts: KV 등록 fire-and-forget (non-blocking) - cache.ts: 메모리 제한 (5000키) - cache.ts: 25초 실행 시간 가드 - cache.ts: 패턴 매칭 prefix 최적화 New features: - 마이그레이션 자동화 시스템 (scripts/migrate.ts) - KV 기반 캐시 인덱스 (invalidatePattern, clearAll) - 글로벌 CacheService 싱글톤 Other: - .env.example 추가, API 키 환경변수 처리 - CACHE_TTL.RECOMMENDATIONS (10분) 분리 - e2e-tester.ts JSON 파싱 에러 핸들링 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
284 lines
8.1 KiB
TypeScript
284 lines
8.1 KiB
TypeScript
/**
|
|
* Recommendation Route Handler
|
|
*
|
|
* Endpoint for getting cloud instance recommendations based on tech stack.
|
|
* Validates request parameters and returns ranked instance recommendations.
|
|
*/
|
|
|
|
import type { Env, ScaleType } from '../types';
|
|
import { RecommendationService } from '../services/recommendation';
|
|
import { validateStack, STACK_REQUIREMENTS } from '../services/stackConfig';
|
|
import { getGlobalCacheService } from '../services/cache';
|
|
import { logger } from '../utils/logger';
|
|
import { HTTP_STATUS, CACHE_TTL, REQUEST_LIMITS } from '../constants';
|
|
import {
|
|
parseJsonBody,
|
|
validateStringArray,
|
|
validateEnum,
|
|
validatePositiveNumber,
|
|
createErrorResponse,
|
|
} from '../utils/validation';
|
|
|
|
/**
|
|
* Request body interface for recommendation endpoint
|
|
*/
|
|
interface RecommendRequestBody {
|
|
stack?: unknown;
|
|
scale?: unknown;
|
|
budget_max?: unknown;
|
|
}
|
|
|
|
/**
|
|
* Supported scale types
|
|
*/
|
|
const SUPPORTED_SCALES: readonly ScaleType[] = ['small', 'medium', 'large'] as const;
|
|
|
|
/**
|
|
* Handle POST /recommend endpoint
|
|
*
|
|
* @param request - HTTP request object
|
|
* @param env - Cloudflare Worker environment bindings
|
|
* @returns JSON response with recommendations
|
|
*
|
|
* @example
|
|
* POST /recommend
|
|
* {
|
|
* "stack": ["nginx", "mysql", "redis"],
|
|
* "scale": "medium",
|
|
* "budget_max": 100
|
|
* }
|
|
*/
|
|
export async function handleRecommend(request: Request, env: Env): Promise<Response> {
|
|
const startTime = Date.now();
|
|
|
|
logger.info('[Recommend] Request received');
|
|
|
|
try {
|
|
// 1. Validate request size to prevent memory exhaustion attacks
|
|
const contentLength = request.headers.get('content-length');
|
|
if (contentLength) {
|
|
const bodySize = parseInt(contentLength, 10);
|
|
if (isNaN(bodySize) || bodySize > REQUEST_LIMITS.MAX_BODY_SIZE) {
|
|
logger.error('[Recommend] Request body too large', {
|
|
contentLength: bodySize,
|
|
maxAllowed: REQUEST_LIMITS.MAX_BODY_SIZE,
|
|
});
|
|
return Response.json(
|
|
{
|
|
success: false,
|
|
error: {
|
|
code: 'PAYLOAD_TOO_LARGE',
|
|
message: `Request body exceeds maximum size of ${REQUEST_LIMITS.MAX_BODY_SIZE} bytes`,
|
|
details: {
|
|
max_size_bytes: REQUEST_LIMITS.MAX_BODY_SIZE,
|
|
received_bytes: bodySize,
|
|
},
|
|
},
|
|
},
|
|
{ status: HTTP_STATUS.PAYLOAD_TOO_LARGE }
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Parse request body
|
|
const parseResult = await parseJsonBody<RecommendRequestBody>(request);
|
|
if (!parseResult.success) {
|
|
logger.error('[Recommend] JSON parsing failed', {
|
|
code: parseResult.error.code,
|
|
message: parseResult.error.message,
|
|
});
|
|
return createErrorResponse(parseResult.error);
|
|
}
|
|
|
|
const body = parseResult.data;
|
|
|
|
// 3. Validate stack parameter
|
|
const stackResult = validateStringArray(body.stack, 'stack');
|
|
if (!stackResult.success) {
|
|
logger.error('[Recommend] Stack validation failed', {
|
|
code: stackResult.error.code,
|
|
message: stackResult.error.message,
|
|
});
|
|
// Add supported stacks to error details
|
|
const enrichedError = {
|
|
...stackResult.error,
|
|
details: {
|
|
...((stackResult.error.details as object) || {}),
|
|
supported: Object.keys(STACK_REQUIREMENTS),
|
|
},
|
|
};
|
|
return createErrorResponse(enrichedError);
|
|
}
|
|
|
|
const stack = stackResult.data;
|
|
|
|
// 4. Validate scale parameter
|
|
const scaleResult = validateEnum(body.scale, 'scale', SUPPORTED_SCALES);
|
|
if (!scaleResult.success) {
|
|
logger.error('[Recommend] Scale validation failed', {
|
|
code: scaleResult.error.code,
|
|
message: scaleResult.error.message,
|
|
});
|
|
return createErrorResponse(scaleResult.error);
|
|
}
|
|
|
|
const scale = scaleResult.data;
|
|
|
|
// 5. Validate budget_max parameter (optional)
|
|
let budgetMax: number | undefined;
|
|
if (body.budget_max !== undefined) {
|
|
const budgetResult = validatePositiveNumber(body.budget_max, 'budget_max');
|
|
if (!budgetResult.success) {
|
|
logger.error('[Recommend] Budget validation failed', {
|
|
code: budgetResult.error.code,
|
|
message: budgetResult.error.message,
|
|
});
|
|
return createErrorResponse(budgetResult.error);
|
|
}
|
|
budgetMax = budgetResult.data;
|
|
}
|
|
|
|
// 6. Validate stack components against supported technologies
|
|
const validation = validateStack(stack);
|
|
if (!validation.valid) {
|
|
logger.error('[Recommend] Unsupported stack components', {
|
|
invalidStacks: validation.invalidStacks,
|
|
});
|
|
return createErrorResponse({
|
|
code: 'INVALID_STACK',
|
|
message: `Unsupported stacks: ${validation.invalidStacks.join(', ')}`,
|
|
details: {
|
|
invalid: validation.invalidStacks,
|
|
supported: Object.keys(STACK_REQUIREMENTS),
|
|
},
|
|
});
|
|
}
|
|
|
|
// 7. Initialize cache service and generate cache key
|
|
logger.info('[Recommend] Validation passed', { stack, scale, budgetMax });
|
|
|
|
const cacheService = getGlobalCacheService(CACHE_TTL.RECOMMENDATIONS, env.RATE_LIMIT_KV);
|
|
|
|
// Generate cache key from sorted stack, scale, and budget
|
|
// Sort stack to ensure consistent cache keys regardless of order
|
|
const sortedStack = [...stack].sort();
|
|
const cacheKey = cacheService.generateKey({
|
|
endpoint: 'recommend',
|
|
stack: sortedStack.join(','),
|
|
scale,
|
|
budget_max: budgetMax ?? 'none',
|
|
});
|
|
|
|
logger.info('[Recommend] Cache key generated', { cacheKey });
|
|
|
|
// 8. Check cache first
|
|
interface CachedRecommendation {
|
|
recommendations: unknown[];
|
|
stack_analysis: unknown;
|
|
metadata: {
|
|
cached?: boolean;
|
|
cache_age_seconds?: number;
|
|
cached_at?: string;
|
|
query_time_ms: number;
|
|
};
|
|
}
|
|
|
|
const cached = await cacheService.get<CachedRecommendation>(cacheKey);
|
|
|
|
if (cached) {
|
|
logger.info('[Recommend] Cache hit', {
|
|
cacheKey,
|
|
age: cached.cache_age_seconds,
|
|
});
|
|
|
|
return Response.json(
|
|
{
|
|
success: true,
|
|
data: {
|
|
...cached.data,
|
|
metadata: {
|
|
...cached.data.metadata,
|
|
cached: true,
|
|
cache_age_seconds: cached.cache_age_seconds,
|
|
cached_at: cached.cached_at,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
status: HTTP_STATUS.OK,
|
|
headers: {
|
|
'Cache-Control': `public, max-age=${CACHE_TTL.RECOMMENDATIONS}`,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
logger.info('[Recommend] Cache miss');
|
|
|
|
// 9. Call recommendation service
|
|
const service = new RecommendationService(env.DB);
|
|
const result = await service.recommend({
|
|
stack,
|
|
scale,
|
|
budget_max: budgetMax,
|
|
});
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
logger.info('[Recommend] Recommendation completed', {
|
|
duration_ms: duration,
|
|
recommendations_count: result.recommendations.length,
|
|
});
|
|
|
|
// Prepare response data with metadata
|
|
const responseData = {
|
|
...result,
|
|
metadata: {
|
|
cached: false,
|
|
query_time_ms: duration,
|
|
},
|
|
};
|
|
|
|
// 10. Store result in cache
|
|
try {
|
|
await cacheService.set(cacheKey, responseData, CACHE_TTL.RECOMMENDATIONS);
|
|
} catch (error) {
|
|
// Graceful degradation: log error but don't fail the request
|
|
logger.error('[Recommend] Cache write failed',
|
|
error instanceof Error ? { message: error.message } : { error: String(error) });
|
|
}
|
|
|
|
return Response.json(
|
|
{
|
|
success: true,
|
|
data: responseData,
|
|
},
|
|
{
|
|
status: HTTP_STATUS.OK,
|
|
headers: {
|
|
'Cache-Control': `public, max-age=${CACHE_TTL.RECOMMENDATIONS}`,
|
|
},
|
|
}
|
|
);
|
|
} catch (error) {
|
|
logger.error('[Recommend] Unexpected error', { error });
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
return Response.json(
|
|
{
|
|
success: false,
|
|
error: {
|
|
code: 'INTERNAL_ERROR',
|
|
message: 'Recommendation request failed. Please try again later.',
|
|
request_id: crypto.randomUUID(),
|
|
details: {
|
|
duration_ms: duration,
|
|
},
|
|
},
|
|
},
|
|
{ status: HTTP_STATUS.INTERNAL_ERROR }
|
|
);
|
|
}
|
|
}
|