diff --git a/src/index.js b/src/index.js index d61159b6..48aea983 100644 --- a/src/index.js +++ b/src/index.js @@ -280,6 +280,138 @@ module.exports = { return this.removeRoute(ctx.params.path); } }, + + /** + * Provides a comprehensive list of all mappings (both explicit aliases and computed routes). + * This is an internal action useful for debugging and generating API documentation. + */ + listAllMappings: { + rest: "GET /list-all-mappings", + params: { + // Group results by routes + grouping: { type: "boolean", optional: true, convert: true }, + // Include the action schema in the response + withActionSchema: { type: "boolean", optional: true, convert: true }, + // Respect the HTTP method defined in the action's `rest` settings + respectRest: { type: "boolean", optional: true, convert: true } + }, + handler(ctx) { + // Get params from the request + const grouping = !!ctx.params.grouping; + const withActionSchema = !!ctx.params.withActionSchema; + const respectRest = !!ctx.params.respectRest; + + // Get the full list of registered actions from the broker + const actionList = this.broker.registry.getActionList({}); + const res = []; + // Symbol to replace $ in internal service names + const internalChar = this.settings.internalServiceSpecialChar != null ? this.settings.internalServiceSpecialChar : "~"; + + // --- Step 1: Process explicitly defined aliases --- + this.aliases.forEach(alias => { + // Skip system actions, which usually start with "$" + if (alias.action && alias.action.charAt(0) === "$") return; + + const obj = { + actionName: alias.action, + path: alias.path, + fullPath: alias.fullPath, + methods: alias.method, + routePath: alias.route.path + }; + + // If requested, add the action schema to the response + if (withActionSchema && alias.action) { + const actionSchema = actionList.find(item => item.name == alias.action); + if (actionSchema && actionSchema.action) obj.action = _.omit(actionSchema.action, ["handler"]); + } + + // Group results by route if specified in params + if (grouping) { + const r = res.find(item => item.route == alias.route); + if (r) r.aliases.push(obj); + else res.push({ route: alias.route, aliases: [obj] }); + } else res.push(obj); + }); + + // --- Step 2: Process automatically computed routes for the 'all' policy --- + this.routes.forEach(route => { + // This block only runs for routes with `mappingPolicy: "all"` + if (route.mappingPolicy !== MAPPING_POLICY_ALL) return; + + actionList.forEach(item => { + const actionName = item.name; + const actionSchema = item.action; + + // Skip actions belonging to the API Gateway service itself to avoid exposing them + if (item.service && (item.service.name == this.name || (item.service.metadata && item.service.metadata.$category == "gateway"))) return; + + // Skip system actions (e.g., "$node.*") + if (actionName && actionName.charAt(0) === "$") return; + + // Consider action visibility. Must be 'published'. + if (actionSchema && actionSchema.visibility != null && actionSchema.visibility != "published") return; + // Apply whitelist and blacklist filters + if (route.hasWhitelist && !this.checkWhitelist(route, actionName)) return; + if (route.hasBlacklist && this.checkBlacklist(route, actionName)) return; + + // Convert action name to a relative URL path (e.g., "posts.create" -> "/posts/create") + let relativePath = actionName.replace(/\./g, "/").replace(/\$/g, internalChar); + let fullPath = addSlashes(route.path) + relativePath; + fullPath = normalizePath(fullPath); + + // Determine HTTP methods + let methods = "*"; // All methods are allowed by default + if (respectRest && actionSchema && actionSchema.rest) { + const rest = actionSchema.rest; + const collect = (r) => { + if (_.isString(r)) { // e.g., "POST /users" + if (r.indexOf(" ") !== -1) return r.split(/\s+/)[0]; + return null; + } else if (_.isObject(r)) { // e.g., { method: "POST", path: "/users" } + return r.method || null; + } + return null; + }; + if (Array.isArray(rest)) { + const ms = _.compact(rest.map(collect)); + if (ms.length > 0) methods = ms.join(", "); + } else { + const m = collect(rest); + if (m) methods = m; + } + } + + const obj = { + actionName, + path: removeTrailingSlashes(relativePath), + fullPath, + methods, + routePath: route.path + }; + + if (withActionSchema && actionSchema) obj.action = _.omit(actionSchema, ["handler"]); + + // Group results if required + if (grouping) { + const r = res.find(item => item.route == route); + if (r) r.aliases.push(obj); + else res.push({ route, aliases: [obj] }); + } else res.push(obj); + }); + }); + + // --- Final processing of grouped results --- + if (grouping) { + res.forEach(item => { + item.path = item.route.path; + delete item.route; + }); + } + + return res; + } + }, }, methods: { @@ -384,7 +516,7 @@ module.exports = { // Not routed. const shouldBreak = this.corsHandler(this.settings, req, res); // check cors settings first - if(shouldBreak) { + if (shouldBreak) { return; } @@ -431,7 +563,7 @@ module.exports = { let params = {}; const shouldBreak = this.corsHandler(route, req, res); - if(shouldBreak) { + if (shouldBreak) { return resolve(true); } @@ -718,7 +850,7 @@ module.exports = { } // Redirect - if (res.statusCode==201 || (res.statusCode >= 300 && res.statusCode < 400 && res.statusCode !== 304)) { + if (res.statusCode == 201 || (res.statusCode >= 300 && res.statusCode < 400 && res.statusCode !== 304)) { const location = ctx.meta.$location; /* istanbul ignore next */ if (!location) { @@ -1526,7 +1658,7 @@ module.exports = { const services = this.broker.registry.getServiceList({ withActions: true, grouping: true }); services.forEach(service => { - if(!service.settings) return; + if (!service.settings) return; const serviceName = service.fullName || getServiceFullname(service); let basePaths = []; diff --git a/test/integration/list-all-mappings.spec.js b/test/integration/list-all-mappings.spec.js new file mode 100644 index 00000000..8a2c6bb5 --- /dev/null +++ b/test/integration/list-all-mappings.spec.js @@ -0,0 +1,171 @@ +"use strict"; + +const { ServiceBroker } = require("moleculer"); +const ApiGateway = require("../../index"); + +describe("Test 'listAllMappings' action", () => { + let broker; + + beforeAll(async () => { + broker = new ServiceBroker({ + logger: false, + nodeID: "test-node" + }); + + // Create dummy services + broker.createService({ + name: "users", + actions: { + list: { + rest: "GET /", + handler() { return []; } + }, + get: { + rest: "GET /:id", + handler() { return {}; } + }, + create: { + rest: "POST /", + visibility: "published", + handler() { return {}; } + }, + secret: { + visibility: "private", + handler() { return "ssh"; } + } + } + }); + + broker.createService({ + name: "posts", + actions: { + find: { + handler() { return []; } + }, + get: { + handler() { return {}; } + } + } + }); + + broker.createService({ + name: "$node", + actions: { + health: { + handler() { return "OK"; } + } + } + }); + + broker.createService(ApiGateway, { + settings: { + internalServiceSpecialChar: "~", + routes: [ + // Route 1: Explicit aliases + { + path: "/api", + aliases: { + "GET /users": "users.list", + "POST /users": "users.create" + } + }, + // Route 2: Auto aliases (mappingPolicy: all) + { + path: "/rpc", + mappingPolicy: "all", + whitelist: ["**"] + }, + // Route 3: Restricted aliases + { + path: "/restricted", + whitelist: ["users.list"] + } + ] + } + }); + + await broker.start(); + }); + + afterAll(async () => { + await broker.stop(); + }); + + it("should list all mappings including aliases and auto-mapped routes", async () => { + const res = await broker.call("api.listAllMappings"); + + expect(res).toBeInstanceOf(Array); + + // 1. Check explicit aliases (Route /api) + // Alias path is relative to route path and normalized (no leading slash) + const userListAlias = res.find(item => item.path === "users" && item.methods === "GET" && item.routePath === "/api"); + expect(userListAlias).toBeDefined(); + expect(userListAlias.fullPath).toBe("/api/users"); + expect(userListAlias.actionName).toBe("users.list"); + + const userCreateAlias = res.find(item => item.path === "users" && item.methods === "POST" && item.routePath === "/api"); + expect(userCreateAlias).toBeDefined(); + expect(userCreateAlias.fullPath).toBe("/api/users"); + expect(userCreateAlias.actionName).toBe("users.create"); + + // 2. Check auto-mapped routes (Route /rpc) + // posts.find -> /rpc/posts/find + const postsFindMap = res.find(item => item.fullPath === "/rpc/posts/find"); + expect(postsFindMap).toBeDefined(); + expect(postsFindMap.actionName).toBe("posts.find"); + expect(postsFindMap.path).toBe("posts/find"); + expect(postsFindMap.methods).toBe("*"); + + // 3. System actions like $node.* are filtered out by default in listAllMappings + const nodeHealthMap = res.find(item => item.actionName === "$node.health"); + expect(nodeHealthMap).toBeUndefined(); + + // 4. Check visibility (private actions should not be listed) + const secretAction = res.find(item => item.actionName === "users.secret"); + expect(secretAction).toBeUndefined(); + }); + + it("should group results by route", async () => { + const res = await broker.call("api.listAllMappings", { grouping: true }); + + expect(res).toBeInstanceOf(Array); + expect(res.length).toBe(3); // 3 routes + + const apiRoute = res.find(r => r.path === "/api"); + expect(apiRoute).toBeDefined(); + expect(apiRoute.aliases.length).toBeGreaterThanOrEqual(2); + + const rpcRoute = res.find(r => r.path === "/rpc"); + expect(rpcRoute).toBeDefined(); + // Should contain posts.*, users.*, $node.* (if whitelisted) + const postsFind = rpcRoute.aliases.find(a => a.actionName === "posts.find"); + expect(postsFind).toBeDefined(); + }); + + it("should include action schema if requested", async () => { + const res = await broker.call("api.listAllMappings", { withActionSchema: true }); + + const userList = res.find(item => item.actionName === "users.list"); + expect(userList).toBeDefined(); + expect(userList.action).toBeDefined(); + expect(userList.action.rest).toBe("GET /"); + }); + + it("should respect whitelist in mappingPolicy: all", async () => { + // Route 3 (/restricted) only whitelists users.list + const res = await broker.call("api.listAllMappings"); + + // Find mappings for /restricted route + const restrictedMappings = res.filter(item => item.routePath === "/restricted"); + + // Should contain users.list + const userList = restrictedMappings.find(item => item.actionName === "users.list"); + expect(userList).toBeDefined(); + expect(userList.fullPath).toBe("/restricted/users/list"); + expect(userList.path).toBe("users/list"); + + // Should NOT contain posts.find + const postsFind = restrictedMappings.find(item => item.actionName === "posts.find"); + expect(postsFind).toBeUndefined(); + }); +});