src/index.js
/*
* moleculer
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
* MIT Licensed
*/
"use strict";
const http = require("http");
const http2 = require("http2");
const https = require("https");
const queryString = require("qs");
const os = require("os");
const kleur = require("kleur");
const { match } = require("moleculer").Utils;
const pkg = require("../package.json");
const _ = require("lodash");
const bodyParser = require("body-parser");
const serveStatic = require("serve-static");
const isReadableStream = require("isstream").isReadable;
const { pipeline } = require('stream');
const { MoleculerError, MoleculerServerError, ServiceNotFoundError } = require("moleculer").Errors;
const { ServiceUnavailableError, NotFoundError, ForbiddenError, RateLimitExceeded, ERR_ORIGIN_NOT_ALLOWED } = require("./errors");
const Alias = require("./alias");
const MemoryStore = require("./memory-store");
const { removeTrailingSlashes, addSlashes, normalizePath, composeThen, generateETag, isFresh } = require("./utils");
const MAPPING_POLICY_ALL = "all";
const MAPPING_POLICY_RESTRICT = "restrict";
function getServiceFullname(svc) {
if (svc.version != null && svc.settings.$noVersionPrefix !== true)
return (typeof (svc.version) == "number" ? "v" + svc.version : svc.version) + "." + svc.name;
return svc.name;
}
const SLASH_REGEX = new RegExp(/\./g);
/**
* Official API Gateway service for Moleculer microservices framework.
*
* @service
*/
module.exports = {
// Default service name
name: "api",
// Default settings
settings: {
// Exposed port
port: process.env.PORT || 3000,
// Exposed IP
ip: process.env.IP || "0.0.0.0",
// Used server instance. If null, it will create a new HTTP(s)(2) server
// If false, it will start without server in middleware mode
server: true,
// Routes
routes: [],
// Log each request (default to "info" level)
logRequest: "info",
// Log the request ctx.params (default to "debug" level)
logRequestParams: "debug",
// Log each response (default to "info" level)
logResponse: "info",
// Log the response data (default to disable)
logResponseData: null,
// If set to true, it will log 4xx client errors, as well
log4XXResponses: false,
// Log the route registration/aliases related activity
logRouteRegistration: "info",
// Use HTTP2 server (experimental)
http2: false,
// HTTP Server Timeout
httpServerTimeout: null,
// Request Timeout. More info: https://github.com/moleculerjs/moleculer-web/issues/206
requestTimeout: 300000, // Sets node.js v18 default timeout: https://nodejs.org/api/http.html#serverrequesttimeout
// Optimize route order
optimizeOrder: true,
// CallOption for the root action `api.rest`
rootCallOptions: null,
// Debounce wait time before call to regenerate aliases when received event "$services.changed"
debounceTime: 500
},
// Service's metadata
metadata: {
$category: "gateway",
$description: "Official API Gateway service",
$official: true,
$package: {
name: pkg.name,
version: pkg.version,
repo: pkg.repository ? pkg.repository.url : null
}
},
actions: {
/**
* REST request handler
*/
rest: {
visibility: "private",
tracing: {
tags: {
params: [
"req.url",
"req.method"
]
},
spanName: ctx => `${ctx.params.req.method} ${ctx.params.req.url}`
},
timeout: 0,
handler(ctx) {
const req = ctx.params.req;
const res = ctx.params.res;
// Set pointers to Context
req.$ctx = ctx;
res.$ctx = ctx;
if (ctx.requestID)
res.setHeader("X-Request-ID", ctx.requestID);
if (!req.originalUrl)
req.originalUrl = req.url;
// Split URL & query params
let parsed = this.parseQueryString(req);
let url = parsed.url;
// Trim trailing slash
if (url.length > 1 && url.endsWith("/"))
url = url.slice(0, -1);
req.parsedUrl = url;
if (!req.query)
req.query = parsed.query;
// Skip if no routes
if (!this.routes || this.routes.length == 0)
return null;
let method = req.method;
if (method == "OPTIONS") {
method = req.headers["access-control-request-method"];
}
// Check aliases
const found = this.resolveAlias(url, method);
if (found) {
const route = found.alias.route;
// Update URLs for middlewares
req.baseUrl = route.path;
req.url = req.originalUrl.substring(route.path.length);
if (req.url.length == 0 || req.url[0] !== "/")
req.url = "/" + req.url;
return this.routeHandler(ctx, route, req, res, found);
}
// Check routes
for (let i = 0; i < this.routes.length; i++) {
const route = this.routes[i];
if (url.startsWith(route.path)) {
// Update URLs for middlewares
req.baseUrl = route.path;
req.url = req.originalUrl.substring(route.path.length);
if (req.url.length == 0 || req.url[0] !== "/")
req.url = "/" + req.url;
return this.routeHandler(ctx, route, req, res);
}
}
return null;
}
},
listAliases: {
rest: "GET /list-aliases",
params: {
grouping: { type: "boolean", optional: true, convert: true },
withActionSchema: { type: "boolean", optional: true, convert: true }
},
handler(ctx) {
const grouping = !!ctx.params.grouping;
const withActionSchema = !!ctx.params.withActionSchema;
const actionList = withActionSchema ? this.broker.registry.getActionList({}) : null;
const res = [];
this.aliases.forEach(alias => {
const obj = {
actionName: alias.action,
path: alias.path,
fullPath: alias.fullPath,
methods: alias.method,
routePath: alias.route.path
};
if (withActionSchema && alias.action) {
const actionSchema = actionList.find(item => item.name == alias.action);
if (actionSchema && actionSchema.action) {
obj.action = _.omit(actionSchema.action, ["handler"]);
}
}
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);
}
});
if (grouping) {
res.forEach(item => {
item.path = item.route.path;
delete item.route;
});
}
return res;
}
},
addRoute: {
params: {
route: { type: "object" },
toBottom: { type: "boolean", optional: true, default: true }
},
visibility: "public",
handler(ctx) {
return this.addRoute(ctx.params.route, ctx.params.toBottom);
}
},
removeRoute: {
params: {
name: { type: "string", optional: true },
path: { type: "string", optional: true }
},
visibility: "public",
handler(ctx) {
if (ctx.params.name != null)
return this.removeRouteByName(ctx.params.name);
return this.removeRoute(ctx.params.path);
}
},
},
methods: {
/**
* Create HTTP server
*/
createServer() {
/* istanbul ignore next */
if (this.server) return;
if (this.settings.https && this.settings.https.key && this.settings.https.cert) {
this.server = this.settings.http2 ? http2.createSecureServer(this.settings.https, this.httpHandler) : https.createServer(this.settings.https, this.httpHandler);
this.isHTTPS = true;
} else {
this.server = this.settings.http2 ? http2.createServer(this.httpHandler) : http.createServer(this.httpHandler);
this.isHTTPS = false;
}
// HTTP server timeout
if (this.settings.httpServerTimeout) {
this.logger.debug("Override default http(s) server timeout:", this.settings.httpServerTimeout);
this.server.setTimeout(this.settings.httpServerTimeout);
}
this.server.requestTimeout = this.settings.requestTimeout;
this.logger.debug("Setting http(s) server request timeout to:", this.settings.requestTimeout);
},
/**
* Default error handling behaviour
*
* @param {HttpRequest} req
* @param {HttpResponse} res
* @param {Error} err
*/
errorHandler(req, res, err) {
// don't log client side errors unless it's configured
if (this.settings.log4XXResponses || (err && !_.inRange(err.code, 400, 500))) {
this.logger.error(" Request error!", err.name, ":", err.message, "\n", err.stack, "\nData:", err.data);
}
this.sendError(req, res, err);
},
corsHandler(settings, req, res) {
// CORS headers
if (settings.cors) {
// Set CORS headers to `res`
this.writeCorsHeaders(settings, req, res, true);
// Is it a Preflight request?
if (req.method == "OPTIONS" && req.headers["access-control-request-method"]) {
// 204 - No content
res.writeHead(204, {
"Content-Length": "0"
});
res.end();
if (settings.logging) {
this.logResponse(req, res);
}
return true;
}
}
return false;
},
/**
* HTTP request handler. It is called from native NodeJS HTTP server.
*
* @param {HttpRequest} req
* @param {HttpResponse} res
* @param {Function} next Call next middleware (for Express)
* @returns {Promise}
*/
async httpHandler(req, res, next) {
// Set pointers to service
req.$startTime = process.hrtime();
req.$service = this;
req.$next = next;
res.$service = this;
res.locals = res.locals || {};
let requestID = req.headers["x-request-id"];
if (req.headers["x-correlation-id"])
requestID = req.headers["x-correlation-id"];
let options = { requestID };
if (this.settings.rootCallOptions) {
if (_.isPlainObject(this.settings.rootCallOptions)) {
Object.assign(options, this.settings.rootCallOptions);
} else if (_.isFunction(this.settings.rootCallOptions)) {
this.settings.rootCallOptions.call(this, options, req, res);
}
}
try {
const result = await this.actions.rest({ req, res }, options);
if (result == null) {
// Not routed.
const shouldBreak = this.corsHandler(this.settings, req, res); // check cors settings first
if(shouldBreak) {
return;
}
// Serve assets static files
if (this.serve) {
this.serve(req, res, err => {
this.logger.debug(err);
this.send404(req, res);
});
return;
}
// If not routed and not served static asset, send 404
this.send404(req, res);
}
} catch (err) {
this.errorHandler(req, res, err);
}
},
/**
* Handle request in the matched route.
*
* @param {Context} ctx
* @param {Route} route
* @param {HttpRequest} req
* @param {HttpResponse} res
* @param {Object} foundAlias
*/
routeHandler(ctx, route, req, res, foundAlias) {
// Pointer to the matched route
req.$route = route;
res.$route = route;
this.logRequest(req);
return new this.Promise(async (resolve, reject) => {
res.once("finish", () => resolve(true));
res.once("close", () => resolve(true));
res.once("error", (err) => reject(err));
try {
await composeThen.call(this, req, res, ...route.middlewares);
let params = {};
const shouldBreak = this.corsHandler(route, req, res);
if(shouldBreak) {
return resolve(true);
}
// Merge params
if (route.opts.mergeParams === false) {
params = { body: req.body, query: req.query };
} else {
const body = _.isObject(req.body) ? req.body : {};
Object.assign(params, body, req.query);
}
req.$params = params; // eslint-disable-line require-atomic-updates
// Resolve action name
let urlPath = req.parsedUrl.slice(route.path.length);
if (urlPath.startsWith("/"))
urlPath = urlPath.slice(1);
// Resolve internal services
urlPath = urlPath.replace(this._isscRe, "$");
let action = urlPath;
// Resolve aliases
if (foundAlias) {
const alias = foundAlias.alias;
this.logger.debug(" Alias:", alias.toString());
if (route.opts.mergeParams === false) {
params.params = foundAlias.params;
} else {
Object.assign(params, foundAlias.params);
}
req.$alias = alias; // eslint-disable-line require-atomic-updates
// Alias handler
return resolve(await this.aliasHandler(req, res, alias));
} else if (route.mappingPolicy == MAPPING_POLICY_RESTRICT) {
// Blocking direct access
return resolve(null);
}
if (!action)
return resolve(null);
// Not found alias, call services by action name
action = action.replace(/\//g, ".");
if (route.opts.camelCaseNames) {
action = action.split(".").map(_.camelCase).join(".");
}
// Alias handler
const result = await this.aliasHandler(req, res, { action, _notDefined: true });
resolve(result);
} catch (err) {
reject(err);
}
});
},
/**
* Alias handler. Call action or call custom function
* - check whitelist
* - Rate limiter
* - Resolve endpoint
* - onBeforeCall
* - Authentication
* - Authorization
* - Call the action
*
* @param {HttpRequest} req
* @param {HttpResponse} res
* @param {Object} alias
* @returns
*/
async aliasHandler(req, res, alias) {
const route = req.$route;
const ctx = req.$ctx;
// Whitelist check
if (alias.action && route.hasWhitelist) {
if (!this.checkWhitelist(route, alias.action)) {
this.logger.debug(` The '${alias.action}' action is not in the whitelist!`);
throw new ServiceNotFoundError({ action: alias.action });
}
}
// Rate limiter
if (route.rateLimit) {
const opts = route.rateLimit;
const store = route.rateLimit.store;
const key = opts.key(req);
if (key) {
const remaining = opts.limit - await store.inc(key);
if (opts.headers) {
res.setHeader("X-Rate-Limit-Limit", opts.limit);
res.setHeader("X-Rate-Limit-Remaining", Math.max(0, remaining));
res.setHeader("X-Rate-Limit-Reset", store.resetTime);
}
if (remaining < 0) {
throw new RateLimitExceeded();
}
}
}
// Resolve endpoint by action name
if (alias.action) {
const endpoint = this.broker.findNextActionEndpoint(alias.action, route.callOptions, ctx);
if (endpoint instanceof Error) {
if (!alias._notDefined && endpoint instanceof ServiceNotFoundError) {
throw new ServiceUnavailableError();
}
throw endpoint;
}
if (endpoint.action.visibility != null && endpoint.action.visibility != "published") {
// Action can't be published
throw new ServiceNotFoundError({ action: alias.action });
}
req.$endpoint = endpoint;
req.$action = endpoint.action;
}
// onBeforeCall handling
if (route.onBeforeCall) {
await route.onBeforeCall.call(this, ctx, route, req, res, alias);
}
// Authentication
if (route.authentication) {
const user = await route.authentication.call(this, ctx, route, req, res, alias);
if (user) {
this.logger.debug("Authenticated user", user);
ctx.meta.user = user;
} else {
this.logger.debug("Anonymous user");
ctx.meta.user = null;
}
}
// Authorization
if (route.authorization) {
await route.authorization.call(this, ctx, route, req, res, alias);
}
// Call the action or alias
if (_.isFunction(alias.handler)) {
// Call custom alias handler
if (route.logging && (this.settings.logRequest && this.settings.logRequest in this.logger))
this.logger[this.settings.logRequest](` Call custom function in '${alias.toString()}' alias`);
await new this.Promise((resolve, reject) => {
alias.handler.call(this, req, res, err => {
if (err)
reject(err);
else
resolve();
});
});
if (alias.action)
return this.callAction(route, alias.action, req, res, alias.type == "stream" ? req : req.$params);
else
throw new MoleculerServerError("No alias handler", 500, "NO_ALIAS_HANDLER", { path: req.originalUrl, alias: _.pick(alias, ["method", "path"]) });
} else if (alias.action) {
return this.callAction(route, alias.action, req, res, alias.type == "stream" ? req : req.$params);
}
},
/**
* Call an action via broker
*
* @param {Object} route Route options
* @param {String} actionName Name of action
* @param {HttpRequest} req Request object
* @param {HttpResponse} res Response object
* @param {Object} params Incoming params from request
* @returns {Promise}
*/
async callAction(route, actionName, req, res, params) {
const ctx = req.$ctx;
try {
// Logging params
if (route.logging) {
if (this.settings.logRequest && this.settings.logRequest in this.logger)
this.logger[this.settings.logRequest](` Call '${actionName}' action`);
if (this.settings.logRequestParams && this.settings.logRequestParams in this.logger)
this.logger[this.settings.logRequestParams](" Params:", params);
}
// Pass the `req` & `res` vars to ctx.params.
if (req.$alias && req.$alias.passReqResToParams) {
params.$req = req;
params.$res = res;
}
const opts = route.callOptions ? { ...route.callOptions } : {};
if (params && params.$params) {
// Transfer URL parameters via meta in case of stream
if (!opts.meta) opts.meta = { $params: params.$params };
else opts.meta.$params = params.$params;
}
// Call the action
let data = await ctx.call(req.$endpoint, params, opts);
// Post-process the response
// onAfterCall handling
if (route.onAfterCall)
data = await route.onAfterCall.call(this, ctx, route, req, res, data);
// Send back the response
this.sendResponse(req, res, data, req.$endpoint.action);
if (route.logging)
this.logResponse(req, res, data);
return true;
} catch (err) {
/* istanbul ignore next */
if (!err)
return; // Cancelling promise chain, no error
throw err;
}
},
/**
* Encode response data
*
* @param {HttpIncomingMessage} req
* @param {HttpResponse} res
* @param {any} data
*/
encodeResponse(req, res, data) {
return JSON.stringify(data);
},
/**
* Convert data & send back to client
*
* @param {HttpIncomingMessage} req
* @param {HttpResponse} res
* @param {any} data
* @param {Object?} action
*/
sendResponse(req, res, data, action) {
const ctx = req.$ctx;
const route = req.$route;
/* istanbul ignore next */
if (res.headersSent) {
this.logger.warn("Headers have already sent.", { url: req.url, action });
return;
}
/* istanbul ignore next */
if (!res.statusCode)
res.statusCode = 200;
// Status code & message
if (ctx.meta.$statusCode) {
res.statusCode = ctx.meta.$statusCode;
}
if (ctx.meta.$statusMessage) {
res.statusMessage = ctx.meta.$statusMessage;
}
// Redirect
if (res.statusCode==201 || (res.statusCode >= 300 && res.statusCode < 400 && res.statusCode !== 304)) {
const location = ctx.meta.$location;
/* istanbul ignore next */
if (!location) {
this.logger.warn(`The 'ctx.meta.$location' is missing for status code '${res.statusCode}'!`);
} else {
res.setHeader("Location", location);
}
}
// Override responseType from action schema
let responseType;
/* istanbul ignore next */
if (action && action.responseType) {
responseType = action.responseType;
}
// Custom headers from action schema
/* istanbul ignore next */
if (action && action.responseHeaders) {
Object.keys(action.responseHeaders).forEach(key => {
res.setHeader(key, action.responseHeaders[key]);
if (key == "Content-Type" && !responseType)
responseType = action.responseHeaders[key];
});
}
// Custom responseType from ctx.meta
if (ctx.meta.$responseType) {
responseType = ctx.meta.$responseType;
}
// Custom headers from ctx.meta
if (ctx.meta.$responseHeaders) {
Object.keys(ctx.meta.$responseHeaders).forEach(key => {
if (key == "Content-Type" && !responseType)
responseType = ctx.meta.$responseHeaders[key];
else
try {
res.setHeader(key, ctx.meta.$responseHeaders[key]);
} catch (error) {
this.logger.warn("Invalid header value", req.url, error);
res.setHeader(key, encodeURI(ctx.meta.$responseHeaders[key]));
}
});
}
if (data == null)
return res.end();
let chunk;
// Buffer
if (Buffer.isBuffer(data)) {
res.setHeader("Content-Type", responseType || "application/octet-stream");
res.setHeader("Content-Length", data.length);
chunk = data;
}
// Buffer from Object
else if (_.isObject(data) && data.type == "Buffer") {
const buf = Buffer.from(data);
res.setHeader("Content-Type", responseType || "application/octet-stream");
res.setHeader("Content-Length", buf.length);
chunk = buf;
}
// Stream
else if (isReadableStream(data)) {
res.setHeader("Content-Type", responseType || "application/octet-stream");
chunk = data;
}
// Object or Array (stringify)
else if (_.isObject(data) || Array.isArray(data)) {
res.setHeader("Content-Type", responseType || "application/json; charset=utf-8");
chunk = this.encodeResponse(req, res, data);
}
// Other (stringify or raw text)
else {
if (!responseType) {
res.setHeader("Content-Type", "application/json; charset=utf-8");
chunk = this.encodeResponse(req, res, data);
} else {
res.setHeader("Content-Type", responseType);
if (_.isString(data))
chunk = data;
else
chunk = data.toString();
}
}
// Auto generate & add ETag
if (route.etag && chunk && !res.getHeader("ETag") && !isReadableStream(chunk)) {
res.setHeader("ETag", generateETag.call(this, chunk, route.etag));
}
// Freshness
if (isFresh(req, res))
res.statusCode = 304;
if (res.statusCode === 204 || res.statusCode === 304) {
res.removeHeader("Content-Type");
res.removeHeader("Content-Length");
res.removeHeader("Transfer-Encoding");
chunk = "";
}
if (req.method === "HEAD") {
// skip body for HEAD
res.end();
} else {
// respond
if (isReadableStream(data)) { //Stream response
pipeline(data, res, err => {
if (err) {
this.logger.warn("Stream got an error.", { err, url: req.url, actionName: action.name })
}
})
} else {
res.end(chunk);
}
}
},
/**
* Middleware for ExpressJS
*
* @returns {Function}
*/
express() {
return (req, res, next) => this.httpHandler(req, res, next);
},
/**
* Send 404 response
*
* @param {HttpIncomingMessage} req
* @param {HttpResponse} res
*/
send404(req, res) {
if (req.$next)
return req.$next();
this.sendError(req, res, new NotFoundError());
},
/**
* Send an error response
*
* @param {HttpIncomingMessage} req
* @param {HttpResponse} res
* @param {Error} err
*/
sendError(req, res, err) {
// Route error handler
if (req.$route && _.isFunction(req.$route.onError))
return req.$route.onError.call(this, req, res, err);
// Global error handler
if (_.isFunction(this.settings.onError))
return this.settings.onError.call(this, req, res, err);
// --- Default error handler
// In middleware mode call the next(err)
if (req.$next)
return req.$next(err);
/* istanbul ignore next */
if (res.headersSent) {
this.logger.warn("Headers have already sent", req.url, err);
return;
}
/* istanbul ignore next */
if (!err || !(err instanceof Error)) {
res.writeHead(500);
res.end("Internal Server Error");
this.logResponse(req, res);
return;
}
/* istanbul ignore next */
if (!(err instanceof MoleculerError)) {
const e = err;
err = new MoleculerError(e.message, e.code || e.status, e.type, e.data);
err.name = e.name;
}
const ctx = req.$ctx;
let responseType = "application/json; charset=utf-8";
if (ctx) {
if (ctx.meta.$responseType) {
responseType = ctx.meta.$responseType;
}
if (ctx.meta.$responseHeaders) {
Object.keys(ctx.meta.$responseHeaders).forEach(key => {
if (key === "Content-Type" && !responseType)
responseType = ctx.meta.$responseHeaders[key];
else
try {
res.setHeader(key, ctx.meta.$responseHeaders[key]);
} catch (error) {
this.logger.warn("Invalid header value", req.url, error);
res.setHeader(key, encodeURI(ctx.meta.$responseHeaders[key]));
}
});
}
}
// Return with the error as JSON object
res.setHeader("Content-type", responseType);
const code = _.isNumber(err.code) && _.inRange(err.code, 400, 599) ? err.code : 500;
res.writeHead(code);
const errObj = this.reformatError(err, req, res);
res.end(errObj !== undefined ? this.encodeResponse(req, res, errObj) : undefined);
this.logResponse(req, res);
},
/**
* Reformatting the error object to response
* @param {Error} err
* @param {HttpIncomingMessage} req
* @param {HttpResponse} res
@returns {Object}
*/
reformatError(err/*, req, res*/) {
return _.pick(err, ["name", "message", "code", "type", "data"]);
},
/**
* Send 302 Redirect
*
* @param {HttpResponse} res
* @param {String} url
* @param {Number} status code
*/
sendRedirect(res, url, code = 302) {
res.writeHead(code, {
"Location": url,
"Content-Length": "0"
});
res.end();
//this.logResponse(req, res);
},
/**
* Split the URL and resolve vars from querystring
*
* @param {any} req
* @returns
*/
parseQueryString(req) {
// Split URL & query params
let url = req.url;
let query = {};
const questionIdx = req.url.indexOf("?", 1);
if (questionIdx !== -1) {
query = queryString.parse(req.url.substring(questionIdx + 1), this.settings.qsOptions);
url = req.url.substring(0, questionIdx);
}
return { query, url };
},
/**
* Log the request
*
* @param {HttpIncomingMessage} req
*/
logRequest(req) {
if (req.$route && !req.$route.logging) return;
if (this.settings.logRequest && this.settings.logRequest in this.logger)
this.logger[this.settings.logRequest](`=> ${req.method} ${req.originalUrl}`);
},
/**
* Return with colored status code
*
* @param {any} code
* @returns
*/
coloringStatusCode(code) {
if (code >= 500)
return kleur.red().bold(code);
if (code >= 400 && code < 500)
return kleur.red().bold(code);
if (code >= 300 && code < 400)
return kleur.cyan().bold(code);
if (code >= 200 && code < 300)
return kleur.green().bold(code);
/* istanbul ignore next */
return code;
},
/**
* Log the response
*
* @param {HttpIncomingMessage} req
* @param {HttpResponse} res
* @param {any} data
*/
logResponse(req, res, data) {
if (req.$route && !req.$route.logging) return;
let time = "";
if (req.$startTime) {
const diff = process.hrtime(req.$startTime);
const duration = (diff[0] + diff[1] / 1e9) * 1000;
if (duration > 1000)
time = kleur.red(`[+${Number(duration / 1000).toFixed(3)} s]`);
else
time = kleur.grey(`[+${Number(duration).toFixed(3)} ms]`);
}
if (this.settings.logResponse && this.settings.logResponse in this.logger)
this.logger[this.settings.logResponse](`<= ${this.coloringStatusCode(res.statusCode)} ${req.method} ${kleur.bold(req.originalUrl)} ${time}`);
/* istanbul ignore next */
if (this.settings.logResponseData && this.settings.logResponseData in this.logger) {
this.logger[this.settings.logResponseData](" Data:", data);
}
},
/**
* Check origin(s)
*
* @param {String} origin
* @param {String|Array<String>} settings
* @returns {Boolean}
*/
checkOrigin(origin, settings) {
if (_.isString(settings)) {
if (settings.indexOf(origin) !== -1)
return true;
if (settings.indexOf("*") !== -1) {
// Based on: https://github.com/hapijs/hapi
// eslint-disable-next-line
const wildcard = new RegExp(`^${_.escapeRegExp(settings).replace(/\\\*/g, ".*").replace(/\\\?/g, ".")}$`);
return origin.match(wildcard);
}
} else if (_.isFunction(settings)) {
return settings.call(this, origin);
} else if (Array.isArray(settings)) {
for (let i = 0; i < settings.length; i++) {
if (this.checkOrigin(origin, settings[i])) {
return true;
}
}
}
return false;
},
/**
* Write CORS header
*
* Based on: https://github.com/expressjs/cors
*
* @param {Object} route
* @param {HttpIncomingMessage} req
* @param {HttpResponse} res
* @param {Boolean} isPreFlight
*/
writeCorsHeaders(route, req, res, isPreFlight) {
/* istanbul ignore next */
if (!route.cors) return;
const origin = req.headers["origin"];
// It's not presented, when it's a local request (origin and target same)
if (!origin)
return;
// Access-Control-Allow-Origin
if (!route.cors.origin || route.cors.origin === "*") {
res.setHeader("Access-Control-Allow-Origin", "*");
} else if (this.checkOrigin(origin, route.cors.origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
} else {
throw new ForbiddenError(ERR_ORIGIN_NOT_ALLOWED);
}
// Access-Control-Allow-Credentials
if (route.cors.credentials === true) {
res.setHeader("Access-Control-Allow-Credentials", "true");
}
// Access-Control-Expose-Headers
if (_.isString(route.cors.exposedHeaders)) {
res.setHeader("Access-Control-Expose-Headers", route.cors.exposedHeaders);
} else if (Array.isArray(route.cors.exposedHeaders)) {
res.setHeader("Access-Control-Expose-Headers", route.cors.exposedHeaders.join(", "));
}
if (isPreFlight) {
// Access-Control-Allow-Headers
if (_.isString(route.cors.allowedHeaders)) {
res.setHeader("Access-Control-Allow-Headers", route.cors.allowedHeaders);
} else if (Array.isArray(route.cors.allowedHeaders)) {
res.setHeader("Access-Control-Allow-Headers", route.cors.allowedHeaders.join(", "));
} else {
// AllowedHeaders doesn't specified, so we send back from req headers
const allowedHeaders = req.headers["access-control-request-headers"];
if (allowedHeaders) {
res.setHeader("Vary", "Access-Control-Request-Headers");
res.setHeader("Access-Control-Allow-Headers", allowedHeaders);
}
}
// Access-Control-Allow-Methods
if (_.isString(route.cors.methods)) {
res.setHeader("Access-Control-Allow-Methods", route.cors.methods);
} else if (Array.isArray(route.cors.methods)) {
res.setHeader("Access-Control-Allow-Methods", route.cors.methods.join(", "));
}
// Access-Control-Max-Age
if (route.cors.maxAge) {
res.setHeader("Access-Control-Max-Age", route.cors.maxAge.toString());
}
}
},
/**
* Check the action name in whitelist
*
* @param {Object} route
* @param {String} action
* @returns {Boolean}
*/
checkWhitelist(route, action) {
// Rewrite to for iterator (faster)
return route.whitelist.find(mask => {
if (_.isString(mask))
return match(action, mask);
else if (_.isRegExp(mask))
return mask.test(action);
}) != null;
},
/**
* Resolve alias names
*
* @param {String} url
* @param {string} [method="GET"]
* @returns {Object} Resolved alas & params
*/
resolveAlias(url, method = "GET") {
for (let i = 0; i < this.aliases.length; i++) {
const alias = this.aliases[i];
if (alias.isMethod(method)) {
const params = alias.match(url);
if (params) {
return { alias, params };
}
}
}
return false;
},
/**
* Add & prepare route from options
* @param {Object} opts
* @param {Boolean} [toBottom=true]
*/
addRoute(opts, toBottom = true) {
const route = this.createRoute(opts);
const idx = this.routes.findIndex(r => this.isEqualRoutes(r, route));
if (idx !== -1) {
// Replace the previous
this.routes[idx] = route;
} else {
// Add new route
if (toBottom)
this.routes.push(route);
else
this.routes.unshift(route);
// Reordering routes
if (this.settings.optimizeOrder)
this.optimizeRouteOrder();
}
return route;
},
/**
* Remove a route by path
* @param {String} path
*/
removeRoute(path) {
const idx = this.routes.findIndex(r => r.opts.path == path);
if (idx !== -1) {
const route = this.routes[idx];
// Clean global aliases for this route
this.aliases = this.aliases.filter(a => a.route != route);
// Remote route
this.routes.splice(idx, 1);
return true;
}
return false;
},
/**
* Remove a route by name
* @param {String} name
*/
removeRouteByName(name) {
const idx = this.routes.findIndex(r => r.opts.name == name);
if (idx !== -1) {
const route = this.routes[idx];
// Clean global aliases for this route
this.aliases = this.aliases.filter(a => a.route != route);
// Remote route
this.routes.splice(idx, 1);
return true;
}
return false;
},
/**
* Optimize route order by route path depth
*/
optimizeRouteOrder() {
this.routes.sort((a, b) => {
let c = addSlashes(b.path).split("/").length - addSlashes(a.path).split("/").length;
if (c == 0) {
// Second level ordering (considering URL params)
c = a.path.split(":").length - b.path.split(":").length;
}
return c;
});
this.logger.debug("Optimized path order: ", this.routes.map(r => r.path));
},
/**
* Create route object from options
*
* @param {Object} opts
* @returns {Object}
*/
createRoute(opts) {
this.logRouteRegistration(`Register route to '${opts.path}'`);
let route = {
name: opts.name,
opts,
middlewares: []
};
if (opts.authorization) {
let fn = this.authorize;
if (_.isString(opts.authorization)) fn = this[opts.authorization];
if (!_.isFunction(fn)) {
this.logger.warn("Define 'authorize' method in the service to enable authorization.");
route.authorization = null;
} else
route.authorization = fn;
}
if (opts.authentication) {
let fn = this.authenticate;
if (_.isString(opts.authentication)) fn = this[opts.authentication];
if (!_.isFunction(fn)) {
this.logger.warn("Define 'authenticate' method in the service to enable authentication.");
route.authentication = null;
} else
route.authentication = fn;
}
// Call options
route.callOptions = opts.callOptions;
// Create body parsers as middlewares
if (opts.bodyParsers == null || opts.bodyParsers === true) {
// Set default JSON body-parser
opts.bodyParsers = {
json: true
};
}
if (opts.bodyParsers) {
const bps = opts.bodyParsers;
Object.keys(bps).forEach(key => {
const opts = _.isObject(bps[key]) ? bps[key] : undefined;
if (bps[key] !== false && key in bodyParser)
route.middlewares.push(bodyParser[key](opts));
});
}
// Logging
route.logging = opts.logging != null ? opts.logging : true;
// ETag
route.etag = opts.etag != null ? opts.etag : this.settings.etag;
// Middlewares
let mw = [];
if (this.settings.use && Array.isArray(this.settings.use) && this.settings.use.length > 0)
mw.push(...this.settings.use);
if (opts.use && Array.isArray(opts.use) && opts.use.length > 0)
mw.push(...opts.use);
if (mw.length > 0) {
route.middlewares.push(...mw);
this.logRouteRegistration(` Registered ${mw.length} middlewares.`);
}
// CORS
if (this.settings.cors || opts.cors) {
// Merge cors settings
route.cors = Object.assign({}, {
origin: "*",
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
}, this.settings.cors, opts.cors);
} else {
route.cors = null;
}
// Rate limiter (Inspired by https://github.com/dotcypress/micro-ratelimit/)
const rateLimit = opts.rateLimit || this.settings.rateLimit;
if (rateLimit) {
let opts = Object.assign({}, {
window: 60 * 1000,
limit: 30,
headers: false,
key: (req) => {
return req.headers["x-forwarded-for"] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.connection.socket.remoteAddress;
}
}, rateLimit);
route.rateLimit = opts;
if (opts.StoreFactory)
route.rateLimit.store = new opts.StoreFactory(opts.window, opts, this.broker);
else
route.rateLimit.store = new MemoryStore(opts.window, opts, this.broker);
}
// Handle whitelist
route.whitelist = opts.whitelist;
route.hasWhitelist = Array.isArray(route.whitelist);
// `onBeforeCall` handler
if (opts.onBeforeCall)
route.onBeforeCall = opts.onBeforeCall;
// `onAfterCall` handler
if (opts.onAfterCall)
route.onAfterCall = opts.onAfterCall;
// `onError` handler
if (opts.onError)
route.onError = opts.onError;
// Create URL prefix
const globalPath = this.settings.path && this.settings.path != "/" ? this.settings.path : "";
route.path = addSlashes(globalPath) + (opts.path || "");
route.path = normalizePath(route.path);
// Create aliases
this.createRouteAliases(route, opts.aliases);
// Optimize aliases order
if (this.settings.optimizeOrder) {
this.optimizeAliasesOrder();
}
// Set alias mapping policy
route.mappingPolicy = opts.mappingPolicy;
if (!route.mappingPolicy) {
const hasAliases = _.isObject(opts.aliases) && Object.keys(opts.aliases).length > 0;
route.mappingPolicy = hasAliases || opts.autoAliases ? MAPPING_POLICY_RESTRICT : MAPPING_POLICY_ALL;
}
this.logRouteRegistration("");
return route;
},
/**
* Create all aliases for route.
*
* @param {Object} route
* @param {Object} aliases
*/
createRouteAliases(route, aliases) {
// Clean previous aliases for this route
this.aliases = this.aliases.filter(a => !this.isEqualRoutes(a.route, route));
// Process aliases definitions from route settings
_.forIn(aliases, (action, matchPath) => {
if (matchPath.startsWith("REST ")) {
this.aliases.push(...this.generateRESTAliases(route, matchPath, action));
} else {
this.aliases.push(this.createAlias(route, matchPath, action));
}
});
if (route.opts.autoAliases) {
this.regenerateAutoAliases(route);
}
},
/**
* Checks whether the routes are same.
*
* @param {Object} routeA
* @param {Object} routeB
* @returns {Boolean}
*/
isEqualRoutes(routeA, routeB) {
if (routeA.name != null && routeB.name != null) {
return routeA.name === routeB.name;
}
return routeA.path === routeB.path;
},
/**
* Generate aliases for REST.
*
* @param {Route} route
* @param {String} path
* @param {*} action
*
* @returns Array<Alias>
*/
generateRESTAliases(route, path, action) {
const p = path.split(/\s+/);
const pathName = p[1];
const pathNameWithoutEndingSlash = pathName.endsWith("/") ? pathName.slice(0, -1) : pathName;
const aliases = {
list: `GET ${pathName}`,
get: `GET ${pathNameWithoutEndingSlash}/:id`,
create: `POST ${pathName}`,
update: `PUT ${pathNameWithoutEndingSlash}/:id`,
patch: `PATCH ${pathNameWithoutEndingSlash}/:id`,
remove: `DELETE ${pathNameWithoutEndingSlash}/:id`
};
let actions = ["list", "get", "create", "update", "patch", "remove"];
if (typeof action !== "string" && (action.only || action.except)) {
if (action.only) {
actions = actions.filter(item => action.only.includes(item));
}
if (action.except) {
actions = actions.filter(item => !action.except.includes(item));
}
action = action.action;
}
return actions.map(item => this.createAlias(route, aliases[item], `${action}.${item}`));
},
/**
* Regenerate aliases automatically if service registry has been changed.
*
* @param {Route} route
*/
regenerateAutoAliases(route) {
this.logRouteRegistration(`♻ Generate aliases for '${route.path}' route...`);
// Clean previous aliases for this route
this.aliases = this.aliases.filter(alias => alias.route != route || !alias._generated);
const processedServices = new Set();
const services = this.broker.registry.getServiceList({ withActions: true, grouping: true });
services.forEach(service => {
if(!service.settings) return;
const serviceName = service.fullName || getServiceFullname(service);
let basePaths = [];
if (_.isString(service.settings.rest)) {
basePaths = [service.settings.rest];
} else if (_.isArray(service.settings.rest)) {
basePaths = service.settings.rest;
} else {
basePaths = [serviceName.replace(SLASH_REGEX, "/")];
}
// Skip multiple instances of services
if (processedServices.has(serviceName)) return;
for (let basePath of basePaths) {
basePath = addSlashes(_.isString(basePath) ? basePath : serviceName.replace(SLASH_REGEX, "/"));
_.forIn(service.actions, action => {
if (action.rest) {
// Check visibility
if (action.visibility != null && action.visibility != "published") return;
// Check whitelist
if (route.hasWhitelist && !this.checkWhitelist(route, action.name)) return;
let restRoutes = [];
if (!_.isArray(action.rest)) {
restRoutes = [action.rest];
} else {
restRoutes = action.rest;
}
for (let restRoute of restRoutes) {
let alias = null;
if (_.isString(restRoute)) {
alias = this.parseActionRestString(restRoute, basePath);
} else if (_.isObject(restRoute)) {
alias = this.parseActionRestObject(restRoute, action.rawName, basePath);
}
if (alias) {
alias.path = removeTrailingSlashes(normalizePath(alias.path));
alias._generated = true;
this.aliases.push(this.createAlias(route, alias, action.name));
}
}
}
processedServices.add(serviceName);
});
}
});
if (this.settings.optimizeOrder) {
this.optimizeAliasesOrder();
}
},
/**
*
*/
parseActionRestString(restRoute, basePath) {
if (restRoute.indexOf(" ") !== -1) {
// Handle route: "POST /import"
const p = restRoute.split(/\s+/);
return {
method: p[0],
path: basePath + p[1]
};
}
// Handle route: "/import". In this case apply to all methods as "* /import"
return {
method: "*",
path: basePath + restRoute
};
},
/**
*
*/
parseActionRestObject(restRoute, rawName, basePath) {
// Handle route: { method: "POST", path: "/other", basePath: "newBasePath" }
return Object.assign({}, restRoute, {
method: restRoute.method || "*",
path: (restRoute.basePath ? restRoute.basePath : basePath) + (restRoute.path ? restRoute.path : rawName)
});
},
/**
* Optimize order of alias path.
*/
optimizeAliasesOrder() {
this.aliases.sort((a, b) => {
let c = addSlashes(b.path).split("/").length - addSlashes(a.path).split("/").length;
if (c == 0) {
// Second level ordering (considering URL params)
c = a.path.split(":").length - b.path.split(":").length;
}
if (c == 0) {
c = a.path.localeCompare(b.path);
}
return c;
});
},
/**
* Create alias for route.
*
* @param {Object} route
* @param {String|Object} matchPath
* @param {String|Object} action
*/
createAlias(route, path, action) {
const alias = new Alias(this, route, path, action);
this.logRouteRegistration(" " + alias.toString());
return alias;
},
/**
* Set log level and log registration route related activities
*
* @param {*} message
*/
logRouteRegistration(message) {
if (
this.settings.logRouteRegistration &&
this.settings.logRouteRegistration in this.logger
)
this.logger[this.settings.logRouteRegistration](message);
},
},
events: {
"$services.changed"() {
this.regenerateAllAutoAliases();
}
},
/**
* Service created lifecycle event handler
*/
created() {
if (this.settings.server !== false) {
if (_.isObject(this.settings.server)) {
// Use an existing server instance
this.server = this.settings.server;
} else {
// Create a new HTTP/HTTPS/HTTP2 server instance
this.createServer();
}
/* istanbul ignore next */
this.server.on("error", err => {
this.logger.error("Server error", err);
});
this.logger.info("API Gateway server created.");
}
// Special char for internal services
const specChar = this.settings.internalServiceSpecialChar != null ? this.settings.internalServiceSpecialChar : "~";
// eslint-disable-next-line security/detect-non-literal-regexp
this._isscRe = new RegExp(specChar);
// Create static server middleware
if (this.settings.assets) {
const opts = this.settings.assets.options || {};
this.serve = serveStatic(this.settings.assets.folder, opts);
}
// Alias store
this.aliases = [];
// Add default route
if (Array.isArray(this.settings.routes) && this.settings.routes.length == 0) {
this.settings.routes = [{
path: "/"
}];
}
// Process routes
this.routes = [];
if (Array.isArray(this.settings.routes))
this.settings.routes.forEach(route => this.addRoute(route));
// Regenerate all auto aliases routes
const debounceTime = this.settings.debounceTime > 0 ? parseInt(this.settings.debounceTime) : 500;
this.regenerateAllAutoAliases = _.debounce(() => {
/* istanbul ignore next */
this.routes.forEach(route => route.opts.autoAliases && this.regenerateAutoAliases(route));
this.broker.broadcast("$api.aliases.regenerated");
}, debounceTime);
},
/**
* Service started lifecycle event handler
*/
started() {
if (this.settings.server === false)
return this.Promise.resolve();
/* istanbul ignore next */
return new this.Promise((resolve, reject) => {
this.server.listen(this.settings.port, this.settings.ip, err => {
if (err)
return reject(err);
const addr = this.server.address();
const listenAddr = addr.address == "0.0.0.0" && os.platform() == "win32" ? "localhost" : addr.address;
this.logger.info(`API Gateway listening on ${this.isHTTPS ? "https" : "http"}://${listenAddr}:${addr.port}`);
resolve();
});
});
},
/**
* Service stopped lifecycle event handler
*/
stopped() {
if (this.settings.server !== false && this.server.listening) {
/* istanbul ignore next */
return new this.Promise((resolve, reject) => {
this.server.close(err => {
if (err)
return reject(err);
this.logger.info("API Gateway stopped!");
resolve();
});
});
}
return this.Promise.resolve();
},
bodyParser,
serveStatic,
Errors: require("./errors"),
RateLimitStores: {
MemoryStore: require("./memory-store")
}
};