From 67d86be5d5419490e80ed929f41d032cd81c4b8f Mon Sep 17 00:00:00 2001 From: kappa Date: Sun, 25 Jan 2026 19:36:34 +0900 Subject: [PATCH] feat: add flexible region matching to servers API - Add shared buildFlexibleRegionConditions() in utils.ts - Add COUNTRY_NAME_TO_REGIONS mapping for country/city expansion - Update servers.ts to use flexible region matching (korea, tokyo, japan, etc.) - Update recommend.ts to use shared function (remove duplicate code) - Fix servers GROUP BY to show all regions (it.id, r.id) - Update CLAUDE.md with single-line curl examples Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 43 +++++++++++++++---------------- src/handlers/recommend.ts | 50 ++++-------------------------------- src/handlers/servers.ts | 16 ++++++------ src/utils.ts | 53 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cbeeea7..957af96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,9 +90,9 @@ Estimates monthly bandwidth based on use_case patterns: Heavy bandwidth (>1TB/month) prefers Linode for included bandwidth. -### Flexible Region Matching (`candidates.ts`) +### Flexible Region Matching (`utils.ts`) -Region matching supports multiple input formats: +Both `/api/recommend` and `/api/servers` use shared `buildFlexibleRegionConditions()`: ```sql LOWER(r.region_code) = ? OR LOWER(r.region_code) LIKE ? OR @@ -100,7 +100,9 @@ LOWER(r.region_name) LIKE ? OR LOWER(r.country_code) = ? ``` -Valid inputs: `"korea"`, `"KR"`, `"seoul"`, `"ap-northeast-2"`, `"icn"` +Valid inputs: `"korea"`, `"KR"`, `"seoul"`, `"tokyo"`, `"japan"`, `"ap-northeast-2"`, `"icn"` + +Country names are auto-expanded via `COUNTRY_NAME_TO_REGIONS` mapping. ### AI Prompt Strategy (`recommend.ts`) @@ -151,29 +153,23 @@ OPENAI_API_KEY = "sk-..." # Set via wrangler secret ## Testing +**Note**: Use single-line curl commands. Backslash line continuation (`\`) may not work in some environments. + ```bash # Health check -curl https://server-recommend.kappa-d8e.workers.dev/api/health +curl -s https://server-recommend.kappa-d8e.workers.dev/api/health | jq . -# Recommendation (e-commerce) -curl -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend \ - -H "Content-Type: application/json" \ - -d '{ - "tech_stack": ["php", "mysql"], - "expected_users": 1000, - "use_case": "e-commerce shopping mall", - "region_preference": ["korea"] - }' +# Recommendation - nodejs/redis real-time chat (Japan) +curl -s -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["nodejs","redis"],"expected_users":1000,"use_case":"real-time chat","region_preference":["japan"]}' | jq . -# Recommendation (analytics - heavier DB workload) -curl -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend \ - -H "Content-Type: application/json" \ - -d '{ - "tech_stack": ["postgresql"], - "expected_users": 500, - "use_case": "analytics dashboard", - "region_preference": ["japan"] - }' +# Recommendation - php/mysql community forum (Korea) +curl -s -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["php","mysql"],"expected_users":800,"use_case":"community forum","region_preference":["korea"]}' | jq . + +# Recommendation - analytics dashboard (heavier DB workload) +curl -s -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["postgresql"],"expected_users":500,"use_case":"analytics dashboard","region_preference":["japan"]}' | jq . + +# Server list with filters (supports flexible region: korea, seoul, tokyo, etc.) +curl -s "https://server-recommend.kappa-d8e.workers.dev/api/servers?region=korea&minCpu=4" | jq . ``` ## Recent Changes @@ -201,5 +197,6 @@ curl -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend \ ### Code Quality - **Dead code removed**: Unused `queryVPSBenchmarks` function deleted -- **DRY**: `DEFAULT_REGION_FILTER_SQL` shared between handlers +- **DRY**: `DEFAULT_REGION_FILTER_SQL` and `buildFlexibleRegionConditions()` shared between handlers - **Name-based filtering**: Provider queries use names, not hardcoded IDs +- **Flexible region matching**: Both endpoints support country/city/code inputs (korea, seoul, icn) diff --git a/src/handlers/recommend.ts b/src/handlers/recommend.ts index fa2b3ea..13a9da3 100644 --- a/src/handlers/recommend.ts +++ b/src/handlers/recommend.ts @@ -28,7 +28,8 @@ import { isValidTechSpec, isValidAIRecommendation, sanitizeForAIPrompt, - DEFAULT_REGION_FILTER_SQL + DEFAULT_REGION_FILTER_SQL, + buildFlexibleRegionConditions } from '../utils'; export async function handleRecommend( @@ -384,52 +385,11 @@ async function queryCandidateServers( } } - // Country name to code mapping for common names - // Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi') - const countryNameToCode: Record = { - 'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'], - 'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'], - 'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'], - 'singapore': ['singapore', 'ap-southeast-1'], - 'indonesia': ['jakarta', 'ap-southeast-3'], - 'india': ['mumbai', 'delhi', 'bangalore', 'hyderabad', 'ap-south-1'], - 'australia': ['sydney', 'melbourne', 'ap-southeast-2'], - 'germany': ['frankfurt', 'nuremberg', 'falkenstein', 'eu-central-1'], - 'usa': ['us-east', 'us-west', 'virginia', 'oregon', 'ohio'], - 'united states': ['us-east', 'us-west', 'virginia', 'oregon', 'ohio'], - 'uk': ['london', 'manchester', 'eu-west-2'], - 'united kingdom': ['london', 'manchester', 'eu-west-2'], - 'netherlands': ['amsterdam', 'eu-west-1'], - 'france': ['paris', 'eu-west-3'], - 'hong kong': ['hong kong', 'ap-east-1'], - 'taiwan': ['taipei', 'ap-northeast-1'], - 'brazil': ['sao paulo', 'sa-east-1'], - 'canada': ['montreal', 'toronto', 'ca-central-1'], - }; - // Flexible region matching: region_code, region_name, or country_code if (req.region_preference && req.region_preference.length > 0) { - // User specified region → filter to that region only - const regionConditions: string[] = []; - for (const region of req.region_preference) { - const lowerRegion = region.toLowerCase(); - - // Expand country names to their codes/cities - const expandedRegions = countryNameToCode[lowerRegion] || [lowerRegion]; - const allRegions = [lowerRegion, ...expandedRegions]; - - for (const r of allRegions) { - const escapedRegion = escapeLikePattern(r); - regionConditions.push(`( - LOWER(r.region_code) = ? OR - LOWER(r.region_code) LIKE ? ESCAPE '\\' OR - LOWER(r.region_name) LIKE ? ESCAPE '\\' OR - LOWER(r.country_code) = ? - )`); - params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r); - } - } - query += ` AND (${regionConditions.join(' OR ')})`; + const { conditions, params: regionParams } = buildFlexibleRegionConditions(req.region_preference); + query += ` AND (${conditions.join(' OR ')})`; + params.push(...regionParams); } else { // No region specified → default to Seoul/Tokyo/Osaka/Singapore query += ` AND ${DEFAULT_REGION_FILTER_SQL}`; diff --git a/src/handlers/servers.ts b/src/handlers/servers.ts index b61b0b3..6647889 100644 --- a/src/handlers/servers.ts +++ b/src/handlers/servers.ts @@ -3,7 +3,7 @@ */ import type { Env } from '../types'; -import { jsonResponse, isValidServer, DEFAULT_REGION_FILTER_SQL } from '../utils'; +import { jsonResponse, isValidServer, DEFAULT_REGION_FILTER_SQL, buildFlexibleRegionConditions } from '../utils'; /** * GET /api/servers - Server list with filtering @@ -42,9 +42,9 @@ export async function handleGetServers( it.instance_family, it.gpu_count, it.gpu_type, - MIN(pr.monthly_price) as monthly_price, - MIN(r.region_name) as region_name, - MIN(r.region_code) as region_code + pr.monthly_price, + r.region_name, + r.region_code FROM instance_types it JOIN providers p ON it.provider_id = p.id JOIN pricing pr ON pr.instance_type_id = it.id @@ -79,11 +79,13 @@ export async function handleGetServers( } if (region) { - query += ` AND r.region_code = ?`; - params.push(region); + // Flexible region matching: supports country names, codes, city names + const { conditions, params: regionParams } = buildFlexibleRegionConditions([region]); + query += ` AND (${conditions.join(' OR ')})`; + params.push(...regionParams); } - query += ` GROUP BY it.id ORDER BY MIN(pr.monthly_price) ASC LIMIT 100`; + query += ` GROUP BY it.id, r.id ORDER BY pr.monthly_price ASC LIMIT 100`; const result = await env.DB.prepare(query).bind(...params).all(); diff --git a/src/utils.ts b/src/utils.ts index 6e0054b..4531dc3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -138,6 +138,59 @@ export function escapeLikePattern(pattern: string): string { return pattern.replace(/[%_\\]/g, '\\$&'); } +/** + * Country name to region code/city mapping for flexible region matching + * Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi') + */ +export const COUNTRY_NAME_TO_REGIONS: Record = { + 'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'], + 'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'], + 'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'], + 'singapore': ['singapore', 'ap-southeast-1'], + 'indonesia': ['jakarta', 'ap-southeast-3'], + 'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'], + 'india': ['mumbai', 'delhi', 'chennai', 'ap-south'], + 'usa': ['us-east', 'us-west', 'us-central', 'america'], + 'us': ['us-east', 'us-west', 'us-central', 'america'], + 'united states': ['us-east', 'us-west', 'us-central', 'america'], + 'uk': ['london', 'uk-south', 'eu-west-2'], + 'united kingdom': ['london', 'uk-south', 'eu-west-2'], + 'germany': ['frankfurt', 'eu-central', 'eu-west-3'], + 'france': ['paris', 'eu-west-3'], + 'netherlands': ['amsterdam', 'eu-west-1'], + 'brazil': ['sao paulo', 'sa-east'], + 'canada': ['toronto', 'montreal', 'ca-central'], +}; + +/** + * Build flexible region matching SQL conditions and parameters + */ +export function buildFlexibleRegionConditions( + regions: string[] +): { conditions: string[]; params: (string | number)[] } { + const conditions: string[] = []; + const params: (string | number)[] = []; + + for (const region of regions) { + const lowerRegion = region.toLowerCase(); + const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion]; + const allRegions = [lowerRegion, ...expandedRegions]; + + for (const r of allRegions) { + const escapedRegion = escapeLikePattern(r); + conditions.push(`( + LOWER(r.region_code) = ? OR + LOWER(r.region_code) LIKE ? ESCAPE '\\' OR + LOWER(r.region_name) LIKE ? ESCAPE '\\' OR + LOWER(r.country_code) = ? + )`); + params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r); + } + } + + return { conditions, params }; +} + /** * Type guard to validate Server object structure */