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 <noreply@anthropic.com>
This commit is contained in:
43
CLAUDE.md
43
CLAUDE.md
@@ -90,9 +90,9 @@ Estimates monthly bandwidth based on use_case patterns:
|
|||||||
|
|
||||||
Heavy bandwidth (>1TB/month) prefers Linode for included bandwidth.
|
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
|
```sql
|
||||||
LOWER(r.region_code) = ? OR
|
LOWER(r.region_code) = ? OR
|
||||||
LOWER(r.region_code) LIKE ? OR
|
LOWER(r.region_code) LIKE ? OR
|
||||||
@@ -100,7 +100,9 @@ LOWER(r.region_name) LIKE ? OR
|
|||||||
LOWER(r.country_code) = ?
|
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`)
|
### AI Prompt Strategy (`recommend.ts`)
|
||||||
|
|
||||||
@@ -151,29 +153,23 @@ OPENAI_API_KEY = "sk-..." # Set via wrangler secret
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
**Note**: Use single-line curl commands. Backslash line continuation (`\`) may not work in some environments.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Health check
|
# 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)
|
# Recommendation - nodejs/redis real-time chat (Japan)
|
||||||
curl -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend \
|
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 .
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"tech_stack": ["php", "mysql"],
|
|
||||||
"expected_users": 1000,
|
|
||||||
"use_case": "e-commerce shopping mall",
|
|
||||||
"region_preference": ["korea"]
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Recommendation (analytics - heavier DB workload)
|
# Recommendation - php/mysql community forum (Korea)
|
||||||
curl -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend \
|
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 .
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
# Recommendation - analytics dashboard (heavier DB workload)
|
||||||
"tech_stack": ["postgresql"],
|
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 .
|
||||||
"expected_users": 500,
|
|
||||||
"use_case": "analytics dashboard",
|
# Server list with filters (supports flexible region: korea, seoul, tokyo, etc.)
|
||||||
"region_preference": ["japan"]
|
curl -s "https://server-recommend.kappa-d8e.workers.dev/api/servers?region=korea&minCpu=4" | jq .
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
@@ -201,5 +197,6 @@ curl -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend \
|
|||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
- **Dead code removed**: Unused `queryVPSBenchmarks` function deleted
|
- **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
|
- **Name-based filtering**: Provider queries use names, not hardcoded IDs
|
||||||
|
- **Flexible region matching**: Both endpoints support country/city/code inputs (korea, seoul, icn)
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import {
|
|||||||
isValidTechSpec,
|
isValidTechSpec,
|
||||||
isValidAIRecommendation,
|
isValidAIRecommendation,
|
||||||
sanitizeForAIPrompt,
|
sanitizeForAIPrompt,
|
||||||
DEFAULT_REGION_FILTER_SQL
|
DEFAULT_REGION_FILTER_SQL,
|
||||||
|
buildFlexibleRegionConditions
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
export async function handleRecommend(
|
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<string, string[]> = {
|
|
||||||
'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
|
// Flexible region matching: region_code, region_name, or country_code
|
||||||
if (req.region_preference && req.region_preference.length > 0) {
|
if (req.region_preference && req.region_preference.length > 0) {
|
||||||
// User specified region → filter to that region only
|
const { conditions, params: regionParams } = buildFlexibleRegionConditions(req.region_preference);
|
||||||
const regionConditions: string[] = [];
|
query += ` AND (${conditions.join(' OR ')})`;
|
||||||
for (const region of req.region_preference) {
|
params.push(...regionParams);
|
||||||
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 ')})`;
|
|
||||||
} else {
|
} else {
|
||||||
// No region specified → default to Seoul/Tokyo/Osaka/Singapore
|
// No region specified → default to Seoul/Tokyo/Osaka/Singapore
|
||||||
query += ` AND ${DEFAULT_REGION_FILTER_SQL}`;
|
query += ` AND ${DEFAULT_REGION_FILTER_SQL}`;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Env } from '../types';
|
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
|
* GET /api/servers - Server list with filtering
|
||||||
@@ -42,9 +42,9 @@ export async function handleGetServers(
|
|||||||
it.instance_family,
|
it.instance_family,
|
||||||
it.gpu_count,
|
it.gpu_count,
|
||||||
it.gpu_type,
|
it.gpu_type,
|
||||||
MIN(pr.monthly_price) as monthly_price,
|
pr.monthly_price,
|
||||||
MIN(r.region_name) as region_name,
|
r.region_name,
|
||||||
MIN(r.region_code) as region_code
|
r.region_code
|
||||||
FROM instance_types it
|
FROM instance_types it
|
||||||
JOIN providers p ON it.provider_id = p.id
|
JOIN providers p ON it.provider_id = p.id
|
||||||
JOIN pricing pr ON pr.instance_type_id = it.id
|
JOIN pricing pr ON pr.instance_type_id = it.id
|
||||||
@@ -79,11 +79,13 @@ export async function handleGetServers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (region) {
|
if (region) {
|
||||||
query += ` AND r.region_code = ?`;
|
// Flexible region matching: supports country names, codes, city names
|
||||||
params.push(region);
|
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();
|
const result = await env.DB.prepare(query).bind(...params).all();
|
||||||
|
|
||||||
|
|||||||
53
src/utils.ts
53
src/utils.ts
@@ -138,6 +138,59 @@ export function escapeLikePattern(pattern: string): string {
|
|||||||
return pattern.replace(/[%_\\]/g, '\\$&');
|
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<string, string[]> = {
|
||||||
|
'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
|
* Type guard to validate Server object structure
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user