moleculerjs/moleculer-web

View on GitHub
src/index.js

Summary

Maintainability
A
0 mins
Test Coverage
/*
 * 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")
    }
};