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>
490 lines
14 KiB
TypeScript
490 lines
14 KiB
TypeScript
/**
|
|
* Instances Route Handler
|
|
*
|
|
* Endpoint for querying instance types with filtering, sorting, and pagination.
|
|
* Integrates with cache service for performance optimization.
|
|
*/
|
|
|
|
import type { Env, InstanceQueryParams } from '../types';
|
|
import { QueryService } from '../services/query';
|
|
import { getGlobalCacheService } from '../services/cache';
|
|
import { logger } from '../utils/logger';
|
|
import {
|
|
SUPPORTED_PROVIDERS,
|
|
type SupportedProvider,
|
|
VALID_SORT_FIELDS,
|
|
INSTANCE_FAMILIES,
|
|
PAGINATION,
|
|
CACHE_TTL,
|
|
HTTP_STATUS,
|
|
} from '../constants';
|
|
|
|
/**
|
|
* Worker-level service singleton cache
|
|
* Performance optimization: Reuse service instances across requests within same Worker instance
|
|
*
|
|
* Benefits:
|
|
* - Reduces GC pressure by avoiding object creation per request
|
|
* - Maintains service state (e.g., logger initialization) across requests
|
|
* - Safe for Cloudflare Workers as Worker instances are isolated and stateless
|
|
*
|
|
* Note: Worker instances are recreated periodically, preventing memory leaks
|
|
*/
|
|
let cachedQueryService: QueryService | null = null;
|
|
let cachedDb: D1Database | null = null;
|
|
|
|
/**
|
|
* Get or create QueryService singleton
|
|
* Lazy initialization on first request, then reused for subsequent requests
|
|
* Invalidates cache if database binding changes (rolling deploy scenario)
|
|
*/
|
|
function getQueryService(db: D1Database, env: Env): QueryService {
|
|
// Invalidate cache if db binding changed (rolling deploy scenario)
|
|
if (!cachedQueryService || cachedDb !== db) {
|
|
cachedQueryService = new QueryService(db, env);
|
|
cachedDb = db;
|
|
logger.debug('[Instances] QueryService singleton initialized/refreshed');
|
|
}
|
|
return cachedQueryService;
|
|
}
|
|
|
|
/**
|
|
* Parsed and validated query parameters
|
|
*/
|
|
interface ParsedQueryParams {
|
|
provider?: string;
|
|
region?: string;
|
|
min_vcpu?: number;
|
|
max_vcpu?: number;
|
|
min_memory_gb?: number;
|
|
max_memory_gb?: number;
|
|
max_price?: number;
|
|
instance_family?: string;
|
|
has_gpu?: boolean;
|
|
sort_by?: string;
|
|
order?: 'asc' | 'desc';
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
/**
|
|
* Validate provider name
|
|
*/
|
|
function isSupportedProvider(provider: string): provider is SupportedProvider {
|
|
return SUPPORTED_PROVIDERS.includes(provider as SupportedProvider);
|
|
}
|
|
|
|
/**
|
|
* Validate sort field
|
|
*/
|
|
function isValidSortField(field: string): boolean {
|
|
return VALID_SORT_FIELDS.includes(field as typeof VALID_SORT_FIELDS[number]);
|
|
}
|
|
|
|
/**
|
|
* Validate instance family
|
|
*/
|
|
function isValidFamily(family: string): boolean {
|
|
return INSTANCE_FAMILIES.includes(family as typeof INSTANCE_FAMILIES[number]);
|
|
}
|
|
|
|
/**
|
|
* Parse and validate query parameters
|
|
*/
|
|
function parseQueryParams(url: URL): {
|
|
params?: ParsedQueryParams;
|
|
error?: { code: string; message: string; parameter?: string };
|
|
} {
|
|
const searchParams = url.searchParams;
|
|
const params: ParsedQueryParams = {
|
|
limit: PAGINATION.DEFAULT_LIMIT,
|
|
offset: PAGINATION.DEFAULT_OFFSET,
|
|
};
|
|
|
|
// Provider validation
|
|
const provider = searchParams.get('provider');
|
|
if (provider !== null) {
|
|
if (!isSupportedProvider(provider)) {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_PARAMETER',
|
|
message: `Invalid provider: ${provider}. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`,
|
|
parameter: 'provider',
|
|
},
|
|
};
|
|
}
|
|
params.provider = provider;
|
|
}
|
|
|
|
// Region (no validation, passed as-is)
|
|
const region = searchParams.get('region');
|
|
if (region !== null) {
|
|
params.region = region;
|
|
}
|
|
|
|
// Numeric parameter validation helper
|
|
function parsePositiveNumber(
|
|
name: string,
|
|
value: string | null
|
|
): number | undefined | { error: { code: string; message: string; parameter: string } } {
|
|
if (value === null) return undefined;
|
|
|
|
const parsed = Number(value);
|
|
if (isNaN(parsed) || parsed < 0) {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_PARAMETER',
|
|
message: `Invalid value for ${name}: must be a positive number`,
|
|
parameter: name,
|
|
},
|
|
};
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
// Parse min_vcpu
|
|
const minVcpuResult = parsePositiveNumber('min_vcpu', searchParams.get('min_vcpu'));
|
|
if (minVcpuResult && typeof minVcpuResult === 'object' && 'error' in minVcpuResult) {
|
|
return minVcpuResult;
|
|
}
|
|
if (typeof minVcpuResult === 'number') {
|
|
params.min_vcpu = minVcpuResult;
|
|
}
|
|
|
|
// Parse max_vcpu
|
|
const maxVcpuResult = parsePositiveNumber('max_vcpu', searchParams.get('max_vcpu'));
|
|
if (maxVcpuResult && typeof maxVcpuResult === 'object' && 'error' in maxVcpuResult) {
|
|
return maxVcpuResult;
|
|
}
|
|
if (typeof maxVcpuResult === 'number') {
|
|
params.max_vcpu = maxVcpuResult;
|
|
}
|
|
|
|
// Parse min_memory_gb
|
|
const minMemoryResult = parsePositiveNumber('min_memory_gb', searchParams.get('min_memory_gb'));
|
|
if (minMemoryResult && typeof minMemoryResult === 'object' && 'error' in minMemoryResult) {
|
|
return minMemoryResult;
|
|
}
|
|
if (typeof minMemoryResult === 'number') {
|
|
params.min_memory_gb = minMemoryResult;
|
|
}
|
|
|
|
// Parse max_memory_gb
|
|
const maxMemoryResult = parsePositiveNumber('max_memory_gb', searchParams.get('max_memory_gb'));
|
|
if (maxMemoryResult && typeof maxMemoryResult === 'object' && 'error' in maxMemoryResult) {
|
|
return maxMemoryResult;
|
|
}
|
|
if (typeof maxMemoryResult === 'number') {
|
|
params.max_memory_gb = maxMemoryResult;
|
|
}
|
|
|
|
// Parse max_price
|
|
const maxPriceResult = parsePositiveNumber('max_price', searchParams.get('max_price'));
|
|
if (maxPriceResult && typeof maxPriceResult === 'object' && 'error' in maxPriceResult) {
|
|
return maxPriceResult;
|
|
}
|
|
if (typeof maxPriceResult === 'number') {
|
|
params.max_price = maxPriceResult;
|
|
}
|
|
|
|
// Instance family validation
|
|
const family = searchParams.get('instance_family');
|
|
if (family !== null) {
|
|
if (!isValidFamily(family)) {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_PARAMETER',
|
|
message: `Invalid instance_family: ${family}. Valid values: ${INSTANCE_FAMILIES.join(', ')}`,
|
|
parameter: 'instance_family',
|
|
},
|
|
};
|
|
}
|
|
params.instance_family = family;
|
|
}
|
|
|
|
// GPU filter (boolean)
|
|
const hasGpu = searchParams.get('has_gpu');
|
|
if (hasGpu !== null) {
|
|
if (hasGpu !== 'true' && hasGpu !== 'false') {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_PARAMETER',
|
|
message: 'Invalid value for has_gpu: must be "true" or "false"',
|
|
parameter: 'has_gpu',
|
|
},
|
|
};
|
|
}
|
|
params.has_gpu = hasGpu === 'true';
|
|
}
|
|
|
|
// Sort by validation
|
|
const sortBy = searchParams.get('sort_by');
|
|
if (sortBy !== null) {
|
|
if (!isValidSortField(sortBy)) {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_PARAMETER',
|
|
message: `Invalid sort_by: ${sortBy}. Valid values: ${VALID_SORT_FIELDS.join(', ')}`,
|
|
parameter: 'sort_by',
|
|
},
|
|
};
|
|
}
|
|
params.sort_by = sortBy;
|
|
}
|
|
|
|
// Sort order validation
|
|
const order = searchParams.get('order');
|
|
if (order !== null) {
|
|
if (order !== 'asc' && order !== 'desc') {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_PARAMETER',
|
|
message: 'Invalid order: must be "asc" or "desc"',
|
|
parameter: 'order',
|
|
},
|
|
};
|
|
}
|
|
params.order = order;
|
|
}
|
|
|
|
// Limit validation
|
|
const limitStr = searchParams.get('limit');
|
|
if (limitStr !== null) {
|
|
const limit = Number(limitStr);
|
|
if (isNaN(limit) || limit < 1 || limit > PAGINATION.MAX_LIMIT) {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_PARAMETER',
|
|
message: `Invalid limit: must be between 1 and ${PAGINATION.MAX_LIMIT}`,
|
|
parameter: 'limit',
|
|
},
|
|
};
|
|
}
|
|
params.limit = limit;
|
|
}
|
|
|
|
// Offset validation
|
|
const offsetStr = searchParams.get('offset');
|
|
if (offsetStr !== null) {
|
|
const offset = Number(offsetStr);
|
|
if (isNaN(offset) || offset < 0) {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_PARAMETER',
|
|
message: 'Invalid offset: must be a non-negative number',
|
|
parameter: 'offset',
|
|
},
|
|
};
|
|
}
|
|
params.offset = offset;
|
|
}
|
|
|
|
// Range consistency validation
|
|
if (params.min_vcpu !== undefined && params.max_vcpu !== undefined) {
|
|
if (params.min_vcpu > params.max_vcpu) {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_RANGE',
|
|
message: 'min_vcpu cannot be greater than max_vcpu',
|
|
parameter: 'min_vcpu',
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
if (params.min_memory_gb !== undefined && params.max_memory_gb !== undefined) {
|
|
if (params.min_memory_gb > params.max_memory_gb) {
|
|
return {
|
|
error: {
|
|
code: 'INVALID_RANGE',
|
|
message: 'min_memory_gb cannot be greater than max_memory_gb',
|
|
parameter: 'min_memory_gb',
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return { params };
|
|
}
|
|
|
|
|
|
/**
|
|
* Handle GET /instances endpoint
|
|
*
|
|
* @param request - HTTP request object
|
|
* @param env - Cloudflare Worker environment bindings
|
|
* @returns JSON response with instance query results
|
|
*
|
|
* @example
|
|
* GET /instances?provider=linode&min_vcpu=2&max_price=20&sort_by=price&order=asc&limit=50
|
|
*/
|
|
export async function handleInstances(
|
|
request: Request,
|
|
env: Env
|
|
): Promise<Response> {
|
|
const startTime = Date.now();
|
|
|
|
logger.info('[Instances] Request received', { url: request.url });
|
|
|
|
try {
|
|
// Parse URL and query parameters
|
|
const url = new URL(request.url);
|
|
const parseResult = parseQueryParams(url);
|
|
|
|
// Handle validation errors
|
|
if (parseResult.error) {
|
|
logger.error('[Instances] Validation error', parseResult.error);
|
|
return Response.json(
|
|
{
|
|
success: false,
|
|
error: parseResult.error,
|
|
},
|
|
{ status: HTTP_STATUS.BAD_REQUEST }
|
|
);
|
|
}
|
|
|
|
const params = parseResult.params!;
|
|
logger.info('[Instances] Query params validated', params as unknown as Record<string, unknown>);
|
|
|
|
// Get global cache service singleton (shared across all routes)
|
|
const cacheService = getGlobalCacheService(CACHE_TTL.INSTANCES, env.RATE_LIMIT_KV);
|
|
|
|
// Generate cache key from query parameters
|
|
const cacheKey = cacheService.generateKey(params as unknown as Record<string, unknown>);
|
|
logger.info('[Instances] Cache key generated', { cacheKey });
|
|
|
|
// Check cache first
|
|
interface CachedData {
|
|
instances: unknown[];
|
|
pagination: {
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
has_more: boolean;
|
|
};
|
|
metadata: {
|
|
cached: boolean;
|
|
last_sync: string;
|
|
query_time_ms: number;
|
|
filters_applied: unknown;
|
|
};
|
|
}
|
|
|
|
const cached = await cacheService.get<CachedData>(cacheKey);
|
|
|
|
if (cached) {
|
|
logger.info('[Instances] 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.INSTANCES}`,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
logger.info('[Instances] Cache miss');
|
|
|
|
// Map route parameters to QueryService parameters
|
|
const queryParams: InstanceQueryParams = {
|
|
provider: params.provider,
|
|
region_code: params.region,
|
|
family: params.instance_family as 'general' | 'compute' | 'memory' | 'storage' | 'gpu' | undefined,
|
|
min_vcpu: params.min_vcpu,
|
|
max_vcpu: params.max_vcpu,
|
|
min_memory: params.min_memory_gb ? params.min_memory_gb * 1024 : undefined, // Convert GB to MB
|
|
max_memory: params.max_memory_gb ? params.max_memory_gb * 1024 : undefined, // Convert GB to MB
|
|
min_price: undefined, // Route doesn't expose min_price
|
|
max_price: params.max_price,
|
|
has_gpu: params.has_gpu,
|
|
sort_by: params.sort_by,
|
|
sort_order: params.order,
|
|
page: Math.floor(params.offset / params.limit) + 1, // Convert offset to page
|
|
limit: params.limit,
|
|
};
|
|
|
|
// Get QueryService singleton (reused across requests)
|
|
const queryService = getQueryService(env.DB, env);
|
|
const result = await queryService.queryInstances(queryParams);
|
|
|
|
const queryTime = Date.now() - startTime;
|
|
|
|
logger.info('[Instances] Query executed', {
|
|
queryTime,
|
|
results: result.data.length,
|
|
total: result.pagination.total_results,
|
|
});
|
|
|
|
// Prepare response data
|
|
const responseData = {
|
|
instances: result.data,
|
|
pagination: {
|
|
total: result.pagination.total_results,
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
has_more: result.pagination.has_next,
|
|
},
|
|
metadata: {
|
|
cached: false,
|
|
last_sync: new Date().toISOString(),
|
|
query_time_ms: queryTime,
|
|
filters_applied: result.meta.filters_applied,
|
|
},
|
|
};
|
|
|
|
// Store result in cache
|
|
try {
|
|
await cacheService.set(cacheKey, responseData, CACHE_TTL.INSTANCES);
|
|
} catch (error) {
|
|
// Graceful degradation: log error but don't fail the request
|
|
logger.error('[Instances] 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.INSTANCES}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
} catch (error) {
|
|
logger.error('[Instances] Unexpected error', { error });
|
|
|
|
return Response.json(
|
|
{
|
|
success: false,
|
|
error: {
|
|
code: 'QUERY_FAILED',
|
|
message: 'Instance query failed. Please try again later.',
|
|
request_id: crypto.randomUUID(),
|
|
},
|
|
},
|
|
{ status: HTTP_STATUS.INTERNAL_ERROR }
|
|
);
|
|
}
|
|
}
|