diff --git a/apps/backend/lambdas/tools/lambda-cli.js b/apps/backend/lambdas/tools/lambda-cli.js index 979de6c..8d7ac97 100644 --- a/apps/backend/lambdas/tools/lambda-cli.js +++ b/apps/backend/lambdas/tools/lambda-cli.js @@ -336,8 +336,11 @@ import * as yaml from 'js-yaml'; export const handler = async (event: APIGatewayProxyEvent): Promise => { try { - const normalizedPath = (event.path || '').replace(/\\/$/, ''); + let normalizedPath = (event.path || '').replace(/\\/$/, ''); const method = (event.httpMethod || 'GET').toUpperCase(); + if (normalizedPath.length === 0) { + normalizedPath = '/'; + } if ((normalizedPath.endsWith('/swagger.json') || normalizedPath === '/swagger.json') && method === 'GET') { const spec = loadOpenApiSpec(); @@ -504,14 +507,18 @@ function addRouteToHandler(handlerPath, method, apiPath, options = {}) { // Generate path parameter extraction using URL parsing let pathParamExtraction = ''; if (pathParams.length > 0) { - const pathParts = apiPath.split('/').filter(Boolean); + // Normalize apiPath to always have leading slash for consistent splitting + const normalizedApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; + const pathParts = normalizedApiPath.split('/'); const extractions = []; pathParts.forEach((part, index) => { if (part.startsWith('{') && part.endsWith('}')) { const paramName = part.slice(1, -1); + // normalizedPath always starts with '/', so split('/') gives ['', ...parts] + // index already accounts for the leading empty string extractions.push( - `const ${paramName} = normalizedPath.split('/')[${index + 1 + `const ${paramName} = normalizedPath.split('/')[${index }];\n if (!${paramName}) return json(400, { message: '${paramName} is required' });`, ); } @@ -555,17 +562,21 @@ function addRouteToHandler(handlerPath, method, apiPath, options = {}) { let matchCondition; if (pathParams.length > 0) { // For paths with parameters, use startsWith and split logic - const pathParts = apiPath.split('/').filter(Boolean); + // Normalize apiPath to always have leading slash for consistent splitting + const normalizedApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; + const pathParts = normalizedApiPath.split('/'); const staticParts = pathParts.filter( (part) => !part.startsWith('{'), ).length; - const pathPrefix = apiPath.split('{')[0]; // Get part before first parameter + const pathPrefix = normalizedApiPath.split('{')[0]; // Get part before first parameter - matchCondition = `normalizedPath.startsWith('${pathPrefix}') && normalizedPath.split('/').length === ${pathParts.length + 1 + // normalizedPath always starts with '/', so split('/') includes leading empty string + matchCondition = `normalizedPath.startsWith('${pathPrefix}') && normalizedPath.split('/').length === ${pathParts.length }`; } else { - // For static paths, use exact match - matchCondition = `normalizedPath === '${apiPath}'`; + // For static paths, normalize to ensure leading slash + const normalizedApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; + matchCondition = `normalizedPath === '${normalizedApiPath}'`; } const codeLines = [ @@ -692,6 +703,194 @@ function methodUpper(m) { return String(m || '').toUpperCase(); } +// Normalize path by replacing path parameters with a placeholder +function normalizePathForComparison(path) { + return path.replace(/\{[^}]+\}/g, '{param}'); +} + +// Extract routes from handler.ts +function extractRoutesFromHandler(handlerPath) { + const source = fs.readFileSync(handlerPath, 'utf8'); + const routes = []; + + // Find the routes section between ROUTES-START and ROUTES-END + const startMarker = '// >>> ROUTES-START'; + const endMarker = '// <<< ROUTES-END'; + const startIndex = source.indexOf(startMarker); + const endIndex = source.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1) { + return routes; + } + + const routesSection = source.substring(startIndex, endIndex); + + // Extract route comments - pattern: // METHOD /path + // The comment typically contains the actual API path + const routeCommentRegex = /\/\/\s*([A-Z]+)\s+([\/\w\{\}\-]+)/g; + + let commentMatch; + while ((commentMatch = routeCommentRegex.exec(routesSection)) !== null) { + const method = commentMatch[1]; + let path = commentMatch[2].trim(); + + // Find the if condition that follows this comment to get the actual method + const afterComment = routesSection.substring(commentMatch.index + commentMatch[0].length); + const ifMatch = afterComment.match(/if\s*\([^)]*method\s*===\s*['"]([A-Z]+)['"]/); + const actualMethod = ifMatch ? ifMatch[1] : method; + + // Ensure path starts with / + if (!path.startsWith('/')) { + path = '/' + path; + } + + // Only add if we have a valid path + if (path && path.startsWith('/')) { + routes.push({ method: actualMethod, path }); + } + } + + return routes; +} + +// Extract routes from openapi.yaml +function extractRoutesFromOpenApi(openapiPath) { + const content = fs.readFileSync(openapiPath, 'utf8'); + const routes = []; + + try { + let yaml; + try { + yaml = require('js-yaml'); + } catch (err) { + // js-yaml not available, fall back to regex parsing + yaml = null; + } + + if (yaml) { + const spec = yaml.load(content); + if (spec && spec.paths) { + for (const [path, methods] of Object.entries(spec.paths)) { + if (typeof methods === 'object' && methods !== null) { + for (const [method, _] of Object.entries(methods)) { + if (['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes(method.toLowerCase())) { + routes.push({ method: method.toUpperCase(), path }); + } + } + } + } + } + } else { + // Fallback: simple regex extraction + const pathRegex = /^\s+([\/\w\{\}]+):/gm; + const methodRegex = /^\s+([a-z]+):/gm; + const lines = content.split('\n'); + let currentPath = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const pathMatch = line.match(/^\s+([\/\w\{\}\-]+):/); + if (pathMatch) { + currentPath = pathMatch[1]; + } else if (currentPath) { + const methodMatch = line.match(/^\s+([a-z]+):/); + if (methodMatch) { + const method = methodMatch[1].toUpperCase(); + if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'].includes(method)) { + routes.push({ method, path: currentPath }); + } + } + } + } + } + } catch (err) { + // If parsing fails completely, return empty array + // Routes from handler.ts will still be checked + } + + return routes; +} + +// Check for similar routes +function checkSimilarRoutes(handlerPath, openapiPath, newMethod, newPath) { + const handlerRoutes = extractRoutesFromHandler(handlerPath); + const openApiRoutes = extractRoutesFromOpenApi(openapiPath); + + // Deduplicate routes (same method + path) + const routeMap = new Map(); + for (const route of [...handlerRoutes, ...openApiRoutes]) { + const key = `${route.method}:${route.path}`; + if (!routeMap.has(key)) { + routeMap.set(key, route); + } + } + const existingRoutes = Array.from(routeMap.values()); + + const newMethodUpper = newMethod.toUpperCase(); + const normalizedNewPath = normalizePathForComparison(newPath); + + const similarRoutes = []; + + for (const route of existingRoutes) { + const existingMethod = route.method.toUpperCase(); + const normalizedExistingPath = normalizePathForComparison(route.path); + + // Check for exact match + if (existingMethod === newMethodUpper && route.path === newPath) { + similarRoutes.push({ + type: 'exact', + route: `${existingMethod} ${route.path}`, + message: `Exact duplicate route found: ${existingMethod} ${route.path}` + }); + } + // Check for similar path structure (same normalized path) + else if (existingMethod === newMethodUpper && normalizedExistingPath === normalizedNewPath) { + similarRoutes.push({ + type: 'similar', + route: `${existingMethod} ${route.path}`, + message: `Similar route found: ${existingMethod} ${route.path} (same path structure with different parameter names)` + }); + } + // Check for potentially conflicting paths + else if (existingMethod === newMethodUpper) { + // Check if paths could conflict (e.g., /users/test vs /users/{id}) + const newPathParts = newPath.split('/'); + const existingPathParts = route.path.split('/'); + + if (newPathParts.length === existingPathParts.length) { + let hasConflict = false; + for (let i = 0; i < newPathParts.length; i++) { + const newPart = newPathParts[i]; + const existingPart = existingPathParts[i]; + + // Conflict if one is a parameter and the other is static, or both are static but different + if ((newPart.startsWith('{') && !existingPart.startsWith('{') && existingPart !== '') || + (!newPart.startsWith('{') && existingPart.startsWith('{') && newPart !== '') || + (!newPart.startsWith('{') && !existingPart.startsWith('{') && newPart !== existingPart && newPart !== '' && existingPart !== '')) { + hasConflict = false; // Not a conflict, they're different + break; + } + + // If both have same prefix before first parameter, might conflict + if (i === 0 && newPart === existingPart && newPathParts.length > 1) { + hasConflict = true; + } + } + + if (hasConflict) { + similarRoutes.push({ + type: 'potential_conflict', + route: `${existingMethod} ${route.path}`, + message: `Potentially conflicting route: ${existingMethod} ${route.path} (may match similar requests)` + }); + } + } + } + } + + return similarRoutes; +} + function cmdInitHandler(nameArg) { if (!nameArg) error('init-handler requires a name, e.g., orders'); const lambdasRoot = path.resolve(__dirname, '..'); @@ -722,6 +921,71 @@ function cmdInitHandler(nameArg) { log(`Swagger UI: http://localhost:3000/${nameArg}/swagger`); } +function cmdListRoutes(handlerRel) { + if (!handlerRel) + error('list-routes requires: '); + const lambdasRoot = path.resolve(__dirname, '..'); + const baseDir = path.resolve(lambdasRoot, handlerRel); + const handlerPath = path.join(baseDir, 'handler.ts'); + const openapiPath = path.join(baseDir, 'openapi.yaml'); + + if (!fs.existsSync(handlerPath)) + error(`handler.ts not found at ${handlerPath} `); + if (!fs.existsSync(openapiPath)) + error(`openapi.yaml not found at ${openapiPath} `); + + const handlerRoutes = extractRoutesFromHandler(handlerPath); + const openApiRoutes = extractRoutesFromOpenApi(openapiPath); + + // Deduplicate routes (same method + path) + const routeMap = new Map(); + for (const route of [...handlerRoutes, ...openApiRoutes]) { + const key = `${route.method}:${route.path}`; + if (!routeMap.has(key)) { + routeMap.set(key, route); + } + } + const allRoutes = Array.from(routeMap.values()); + + // Sort routes by method, then by path + allRoutes.sort((a, b) => { + if (a.method !== b.method) { + return a.method.localeCompare(b.method); + } + return a.path.localeCompare(b.path); + }); + + if (allRoutes.length === 0) { + log(`No routes found in handler: ${handlerRel}`); + log('Use "add-route" to add routes.'); + return; + } + + log(`\nRoutes in handler: ${handlerRel}`); + log('─'.repeat(60)); + + // Group by method for better readability + const routesByMethod = {}; + for (const route of allRoutes) { + if (!routesByMethod[route.method]) { + routesByMethod[route.method] = []; + } + routesByMethod[route.method].push(route.path); + } + + const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']; + for (const method of methods) { + if (routesByMethod[method]) { + for (const routePath of routesByMethod[method]) { + log(`${method.padEnd(8)} ${routePath}`); + } + } + } + + log('─'.repeat(60)); + log(`Total: ${allRoutes.length} route(s)\n`); +} + function cmdAddRoute(handlerRel, method, apiPath, options = {}) { if (!handlerRel || !method || !apiPath) error('add-route requires: [options]'); @@ -734,6 +998,23 @@ function cmdAddRoute(handlerRel, method, apiPath, options = {}) { if (!fs.existsSync(openapiPath)) error(`openapi.yaml not found at ${openapiPath} `); + // Check for similar routes before adding + const similarRoutes = checkSimilarRoutes(handlerPath, openapiPath, method, apiPath); + if (similarRoutes.length > 0) { + log(''); + log('⚠️ Warning: Similar routes detected:'); + for (const similar of similarRoutes) { + if (similar.type === 'exact') { + error(`Cannot add route: ${similar.message}`); + } else { + log(` - ${similar.message}`); + } + } + log(''); + log('Proceeding with route addition...'); + log(''); + } + addRouteToHandler(handlerPath, method, apiPath, options); addRouteToOpenApi(openapiPath, method, apiPath, options); log(`Added route ${method.toUpperCase()} ${apiPath} to ${handlerRel} `); @@ -756,6 +1037,9 @@ function main() { ' --status Response status code (default: 200)', ); log(''); + log(' list-routes '); + log(' Lists all routes in a handler'); + log(''); log(' Examples:'); log(' node lambda-cli.js init-handler users'); log(' node lambda-cli.js add-route users GET /users/{id}'); @@ -768,6 +1052,7 @@ function main() { log( ' node lambda-cli.js add-route users POST /users --body name:string --headers authorization:string --status 201', ); + log(' node lambda-cli.js list-routes users'); process.exit(0); } if (cmd === 'init-handler') { @@ -775,6 +1060,11 @@ function main() { cmdInitHandler(name); return; } + if (cmd === 'list-routes') { + const handlerRel = rest[0]; + cmdListRoutes(handlerRel); + return; + } if (cmd === 'add-route') { const [handlerRel, method, apiPath, ...flags] = rest; const options = {}; diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index b120d67..21655eb 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -8,7 +8,10 @@ export const handler = async (event: any): Promise => { // API Gateway: event.path, event.httpMethod // Function URL: event.rawPath, event.requestContext.http.method const rawPath = event.rawPath || event.path || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); + let normalizedPath = rawPath.replace(/\/$/, ''); + if (normalizedPath.length === 0) { + normalizedPath = '/'; + } const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); console.log('DEBUG - rawPath:', rawPath, 'normalizedPath:', normalizedPath, 'method:', method);