Skip to content
Open
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
140 changes: 136 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -431,7 +563,7 @@ module.exports = {
let params = {};

const shouldBreak = this.corsHandler(route, req, res);
if(shouldBreak) {
if (shouldBreak) {
return resolve(true);
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = [];
Expand Down
171 changes: 171 additions & 0 deletions test/integration/list-all-mappings.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});