1935 lines
52 KiB
TypeScript
1935 lines
52 KiB
TypeScript
/*
|
|
* moleculer
|
|
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
|
|
* MIT Licensed
|
|
*/
|
|
|
|
import http from 'http';
|
|
import http2 from 'http2';
|
|
import https from 'https';
|
|
import queryString from 'qs';
|
|
import os from 'os';
|
|
import kleur from 'kleur';
|
|
|
|
import _ from 'lodash';
|
|
import bodyParser from 'body-parser';
|
|
import serveStatic from 'serve-static';
|
|
import { isReadable as isReadableStream } from 'isstream';
|
|
|
|
import { Errors, ServiceSchema, Utils } from 'moleculer';
|
|
const { MoleculerError, MoleculerServerError, ServiceNotFoundError } = Errors;
|
|
const match = Utils.match;
|
|
import {
|
|
ServiceUnavailableError,
|
|
NotFoundError,
|
|
ForbiddenError,
|
|
RateLimitExceeded,
|
|
ERR_ORIGIN_NOT_ALLOWED,
|
|
} from './errors';
|
|
|
|
import { Alias } from './alias';
|
|
import { MemoryStore } from './memory-store';
|
|
|
|
import {
|
|
removeTrailingSlashes,
|
|
addSlashes,
|
|
normalizePath,
|
|
composeThen,
|
|
generateETag,
|
|
isFresh,
|
|
} from './utils';
|
|
|
|
const MAPPING_POLICY_ALL = 'all';
|
|
const MAPPING_POLICY_RESTRICT = 'restrict';
|
|
|
|
const pkg = {
|
|
name: 'moleculer-web',
|
|
version: '0.10.5',
|
|
repository: {
|
|
type: 'git',
|
|
url: 'https://github.com/moleculerjs/moleculer-web.git',
|
|
},
|
|
};
|
|
|
|
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
|
|
*/
|
|
export const ApiGatewayMixin: ServiceSchema = {
|
|
// 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}`,
|
|
} as any,
|
|
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
|
|
const 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: any = {
|
|
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'];
|
|
|
|
const 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: any = {};
|
|
|
|
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: any, 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 res.setHeader(key, 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 as any).type == 'Buffer') {
|
|
const buf = Buffer.from(data as any);
|
|
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
|
|
data.pipe(res);
|
|
} 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 as any;
|
|
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 res.setHeader(key, 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));
|
|
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.url}`);
|
|
},
|
|
|
|
/**
|
|
* 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}'`);
|
|
const route: any = {
|
|
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
|
|
const mw: any[] = [];
|
|
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) {
|
|
const 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);
|
|
}
|
|
}
|
|
|
|
// 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 (const 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
|
|
: '~';
|
|
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'),
|
|
},
|
|
};
|