/** * 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 { 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(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(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 } ); } }