Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
306 changes: 298 additions & 8 deletions apps/backend/lambdas/tools/lambda-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,11 @@ import * as yaml from 'js-yaml';

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
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();
Expand Down Expand Up @@ -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' });`,
);
}
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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, '..');
Expand Down Expand Up @@ -722,6 +921,71 @@ function cmdInitHandler(nameArg) {
log(`Swagger UI: http://localhost:3000/${nameArg}/swagger`);
}

function cmdListRoutes(handlerRel) {
if (!handlerRel)
error('list-routes requires: <handlerRel>');
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: <handlerRel> <METHOD> <path> [options]');
Expand All @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! This check looks like it will come in handy in the future, nice

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} `);
Expand All @@ -756,6 +1037,9 @@ function main() {
' --status <code> Response status code (default: 200)',
);
log('');
log(' list-routes <handlerRel>');
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}');
Expand All @@ -768,13 +1052,19 @@ 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') {
const name = rest[0];
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 = {};
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/lambdas/users/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
// 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);
Expand Down