UNPKG

moleculer-web

Version:

Official API Gateway service for Moleculer framework

1,793 lines (1,518 loc) 48.2 kB
/* * 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 * - check blacklist * - 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 }); } } // Blacklist check if (alias.action && route.hasBlacklist) { if (this.checkBlacklist(route, alias.action)) { this.logger.debug(` The '${alias.action}' action is in the blacklist!`); 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; }, /** * Check the action name in blacklist * * @param {Object} route * @param {String} action * @returns {Boolean} */ checkBlacklist(route, action) { // Rewrite to for iterator (faster) return ( route.blacklist.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); // Handle blacklist route.blacklist = opts.blacklist; route.hasBlacklist = Array.isArray(route.blacklist); // `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; // Blacklist check if (route.hasBlacklist) { if (this.checkBlacklist(route, action.name)) { this.logger.debug( ` The '${action.name}' action is in the blacklist!` ); throw new ServiceNotFoundError({ action: action.name, }); } } 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") } };