moleculer-web
Advanced tools
Comparing version
104
.eslintrc.js
module.exports = { | ||
root: true, | ||
"env": { | ||
"node": true, | ||
"commonjs": true, | ||
"es6": true, | ||
"jquery": false, | ||
"jest": true, | ||
"jasmine": true | ||
}, | ||
"extends": "eslint:recommended", | ||
"parserOptions": { | ||
"sourceType": "module" | ||
}, | ||
"rules": { | ||
"indent": [ | ||
"warn", | ||
"tab" | ||
], | ||
"quotes": [ | ||
"warn", | ||
"double" | ||
], | ||
"semi": [ | ||
"error", | ||
"always" | ||
], | ||
"no-var": [ | ||
"error" | ||
], | ||
"no-console": [ | ||
"off" | ||
], | ||
"no-unused-vars": [ | ||
"warn" | ||
] | ||
} | ||
}; | ||
"env": { | ||
"node": true, | ||
"commonjs": true, | ||
"es6": true, | ||
"jquery": false, | ||
"jest": true, | ||
"jasmine": true | ||
}, | ||
"extends": [ | ||
"eslint:recommended", | ||
"plugin:security/recommended" | ||
], | ||
"parserOptions": { | ||
"sourceType": "module", | ||
"ecmaVersion": "2017" | ||
}, | ||
"plugins": [ | ||
"promise", | ||
"security" | ||
], | ||
"rules": { | ||
"indent": [ | ||
"warn", | ||
"tab", | ||
{ SwitchCase: 1 } | ||
], | ||
"quotes": [ | ||
"warn", | ||
"double" | ||
], | ||
"semi": [ | ||
"error", | ||
"always" | ||
], | ||
"no-var": [ | ||
"error" | ||
], | ||
"no-console": [ | ||
"error" | ||
], | ||
"no-unused-vars": [ | ||
"warn" | ||
], | ||
"no-trailing-spaces": [ | ||
"error" | ||
], | ||
"no-alert": 0, | ||
"no-shadow": 0, | ||
"security/detect-object-injection": ["off"], | ||
"security/detect-non-literal-require": ["off"], | ||
"security/detect-non-literal-fs-filename": ["off"], | ||
"no-process-exit": ["off"], | ||
"node/no-unpublished-require": 0, | ||
"space-before-function-paren": [ | ||
"warn", | ||
{ | ||
"anonymous": "never", | ||
"named": "never", | ||
"asyncArrow": "always" | ||
} | ||
], | ||
"object-curly-spacing": [ | ||
"warn", | ||
"always" | ||
] | ||
} | ||
}; |
283
CHANGELOG.md
----------------------------- | ||
<a name="0.9.0"></a> | ||
# 0.9.0 (2018-xx-xx) | ||
## Breaking changes | ||
### Use `server` property instead of `middleware` | ||
We have removed the `middleware` service setting because it was not straightforward. Therefore, we have created a new `server` setting. | ||
If `server: true` (which is the default value), API Gateway will create a HTTP(s) server. If `server: false`, it won't create a HTTP server, so you can use API Gateway as an Express middleware. | ||
#### Migration guide | ||
**Before** | ||
```js | ||
const ApiGateway = require("moleculer-web"); | ||
module.exports = { | ||
mixins: [ApiGateway], | ||
settings: { | ||
middleware: true | ||
} | ||
} | ||
``` | ||
**After** | ||
```js | ||
const ApiGateway = require("moleculer-web"); | ||
module.exports = { | ||
mixins: [ApiGateway], | ||
settings: { | ||
server: false | ||
} | ||
} | ||
``` | ||
## New | ||
### File upload aliases | ||
API Gateway has implemented file uploads. You can upload files as a multipart form data (thanks for busboy library) or as a raw request body. In both cases, the file is transferred to an action as a Stream. In multipart form data mode you can upload multiple files, as well. | ||
> Please note, you have to disable other body parsers in order to accept files. | ||
**Example** | ||
```js | ||
const ApiGateway = require("moleculer-web"); | ||
module.exports = { | ||
mixins: [ApiGateway], | ||
settings: { | ||
path: "/upload", | ||
routes: [ | ||
{ | ||
path: "", | ||
// You should disable body parsers | ||
bodyParsers: { | ||
json: false, | ||
urlencoded: false | ||
}, | ||
aliases: { | ||
// File upload from HTML multipart form | ||
"POST /": "multipart:file.save", | ||
// File upload from AJAX or cURL | ||
"PUT /": "stream:file.save", | ||
// File upload from HTML form and overwrite busboy config | ||
"POST /multi": { | ||
type: "multipart", | ||
// Action level busboy config | ||
busboyConfig: { | ||
limits: { | ||
files: 3 | ||
} | ||
}, | ||
action: "file.save" | ||
} | ||
}, | ||
// Route level busboy config. | ||
// More info: https://github.com/mscdex/busboy#busboy-methods | ||
busboyConfig: { | ||
limits: { | ||
files: 1 | ||
} | ||
}, | ||
mappingPolicy: "restrict" | ||
} | ||
] | ||
} | ||
}); | ||
``` | ||
### HTTP2 server | ||
HTTP2 experimental server has been implemented into API Gateway. You can turn it on with `http2: true` service setting. | ||
**Example** | ||
```js | ||
const ApiGateway = require("moleculer-web"); | ||
module.exports = { | ||
mixins: [ApiGateway], | ||
settings: { | ||
port: 8443, | ||
// HTTPS server with certificate | ||
https: { | ||
key: fs.readFileSync("key.pem"), | ||
cert: fs.readFileSync("cert.pem") | ||
}, | ||
// Use HTTP2 server | ||
http2: true | ||
} | ||
}); | ||
``` | ||
### Dynamic routing | ||
The `this.addRoute(opts, toBottom = true)` new service method is added to add/replace routes. You can call it from your mixins to define new routes _(e.g. swagger route, graphql route...etc)_. | ||
The function detects that the route is defined early. In this case, it will replace the previous route configuration with the new one. | ||
To remove a route, use the `this.removeRoute("/admin")` method. It removes the route by path. | ||
### ETag supporting | ||
Thank to tiaod for ETag implementation. PR: [#92](https://github.com/moleculerjs/moleculer-web/pull/92) | ||
**Example** | ||
```js | ||
const ApiGateway = require("moleculer-web"); | ||
module.exports = { | ||
mixins: [ApiGateway], | ||
settings: { | ||
// Service-level option | ||
etag: false, | ||
routes: [ | ||
{ | ||
path: "/", | ||
// Route-level option. | ||
etag: true | ||
} | ||
] | ||
} | ||
} | ||
``` | ||
The `etag` option value can be `false`, `true`, `weak`, `strong`, or a custom Function. | ||
**Custom ETag generator function** | ||
```js | ||
module.exports = { | ||
mixins: [ApiGateway], | ||
settings: { | ||
// Service-level option | ||
etag: (body) => generateHash(body) | ||
} | ||
} | ||
``` | ||
Please note, it doesn't work with stream responses. In this case, you should generate the etag by yourself. | ||
**Example** | ||
```js | ||
module.exports = { | ||
name: "export", | ||
actions: { | ||
// Download response as a file in the browser | ||
downloadCSV(ctx) { | ||
ctx.meta.$responseType = "text/csv"; | ||
ctx.meta.$responseHeaders = { | ||
"Content-Disposition": `attachment; filename="data-${ctx.params.id}.csv"`, | ||
"ETag": '<your etag here>' | ||
}; | ||
return csvFileStream; | ||
} | ||
} | ||
} | ||
``` | ||
### Auto-aliasing feature | ||
The auto-aliasing means you don't have to add all service aliases to the routes, the Gateway can generate it from service schema. If a new service is entered or leaved, Gateway regenerate aliases. | ||
To configure which services are used in route use the whitelist. | ||
**Example** | ||
```js | ||
// api.service.js | ||
module.exports = { | ||
mixins: [ApiGateway], | ||
settings: { | ||
routes: [ | ||
{ | ||
path: "/api", | ||
whitelist: [ | ||
"posts.*", | ||
"test.*" | ||
], | ||
aliases: { | ||
"GET /hi": "test.hello" | ||
}, | ||
autoAliases: true | ||
} | ||
] | ||
} | ||
}; | ||
``` | ||
```js | ||
// posts.service.js | ||
module.exports = { | ||
name: "posts", | ||
version: 2, | ||
settings: { | ||
// Base path | ||
rest: "posts/" | ||
}, | ||
actions: { | ||
list: { | ||
// Expose as "/v2/posts/" | ||
rest: "GET /", | ||
handler(ctx) {} | ||
}, | ||
get: { | ||
// Expose as "/v2/posts/:id" | ||
rest: "GET /:id", | ||
handler(ctx) {} | ||
}, | ||
create: { | ||
rest: "POST /", | ||
handler(ctx) {} | ||
}, | ||
update: { | ||
rest: "PUT /:id", | ||
handler(ctx) {} | ||
}, | ||
remove: { | ||
rest: "DELETE /:id", | ||
handler(ctx) {} | ||
} | ||
} | ||
}; | ||
``` | ||
**The generated aliases** | ||
``` | ||
GET /api/hi => test.hello | ||
GET /api/v2/posts => v2.posts.list | ||
GET /api/v2/posts/:id => v2.posts.get | ||
POST /api/v2/posts => v2.posts.create | ||
PUT /api/v2/posts/:id => v2.posts.update | ||
DELETE /api/v2/posts/:id => v2.posts.remove | ||
``` | ||
> If `rest: true` in service settings, API Gateway will use the service name (with version) as base path. | ||
> If `rest: true` in action definition, API Gateway will use action name in path. | ||
## Changes | ||
- new `optimizeOrder: true` setting in order to optimize route paths (deeper first). Defaults: `true`. | ||
----------------------------- | ||
<a name="0.8.5"></a> | ||
@@ -6,2 +282,9 @@ # 0.8.5 (2018-11-28) | ||
## Changes | ||
- allow multiple whitespaces between method & path in aliases. | ||
----------------------------- | ||
<a name="0.8.4"></a> | ||
# 0.8.4 (2018-11-18) | ||
## Changes | ||
- fix `req.url`, add `req.originalUrl` and `req.baseUrl` for better middleware support (e.g. support static serving in subpath). | ||
@@ -8,0 +291,0 @@ - update deps |
{ | ||
"name": "moleculer-web", | ||
"version": "0.8.5", | ||
"version": "0.9.0-beta1", | ||
"description": "Official API Gateway service for Moleculer framework", | ||
@@ -12,4 +12,5 @@ "main": "index.js", | ||
"ci": "jest --watch", | ||
"test": "jest --coverage", | ||
"test": "jest --coverage --forceExit", | ||
"lint": "eslint --ext=.js src", | ||
"lint:fix": "eslint --ext=.js --fix src", | ||
"deps": "npm-check -u", | ||
@@ -38,2 +39,5 @@ "postdeps": "npm test", | ||
"eslint": "5.9.0", | ||
"eslint-plugin-node": "8.0.0", | ||
"eslint-plugin-promise": "4.0.1", | ||
"eslint-plugin-security": "1.4.0", | ||
"express": "4.16.4", | ||
@@ -49,11 +53,10 @@ "fakerator": "0.3.0", | ||
"moleculer-console-tracer": "0.2.0", | ||
"moleculer-repl": "0.5.2", | ||
"multer": "1.4.1", | ||
"multiparty": "4.2.1", | ||
"moleculer-repl": "0.5.3", | ||
"nats": "1.0.1", | ||
"nodemon": "1.18.7", | ||
"npm-check": "5.9.0", | ||
"socket.io": "2.1.1", | ||
"socket.io": "2.2.0", | ||
"spdy": "4.0.0", | ||
"supertest": "3.3.0", | ||
"webpack": "4.26.1", | ||
"webpack": "4.27.1", | ||
"webpack-dev-middleware": "3.4.0" | ||
@@ -66,4 +69,7 @@ }, | ||
"body-parser": "1.18.3", | ||
"busboy": "0.2.14", | ||
"chalk": "2.4.1", | ||
"es6-error": "4.1.1", | ||
"etag": "1.8.1", | ||
"fresh": "0.5.2", | ||
"isstream": "0.1.2", | ||
@@ -70,0 +76,0 @@ "lodash": "4.17.11", |
@@ -21,2 +21,3 @@ [](https://github.com/moleculerjs/moleculer) | ||
* support global, route, alias middlewares | ||
* support file uploading | ||
* alias names (with named parameters & REST shorthand) | ||
@@ -26,2 +27,4 @@ * whitelist | ||
* CORS headers | ||
* ETags | ||
* HTTP2 | ||
* Rate limiter | ||
@@ -28,0 +31,0 @@ * before & after call hooks |
524
src/index.js
@@ -19,27 +19,22 @@ /* | ||
const isReadableStream = require("isstream").isReadable; | ||
const pathToRegexp = require("path-to-regexp"); | ||
const { MoleculerError, MoleculerServerError, ServiceNotFoundError } = require("moleculer").Errors; | ||
const { BadRequestError, NotFoundError, ForbiddenError, RateLimitExceeded, ERR_UNABLE_DECODE_PARAM, ERR_ORIGIN_NOT_ALLOWED } = require("./errors"); | ||
const { 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 decodeParam(param) { | ||
try { | ||
return decodeURIComponent(param); | ||
} catch (_) { | ||
/* istanbul ignore next */ | ||
throw BadRequestError(ERR_UNABLE_DECODE_PARAM, { param }); | ||
} | ||
} | ||
/** | ||
* Official API Gateway service for Moleculer | ||
* Official API Gateway service for Moleculer microservices framework. | ||
* | ||
* @service | ||
*/ | ||
module.exports = { | ||
// Service name | ||
// Default service name | ||
name: "api", | ||
@@ -49,4 +44,2 @@ | ||
settings: { | ||
// Middleware mode for ExpressJS | ||
middleware: false, | ||
@@ -59,4 +52,9 @@ // Exposed port | ||
// 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: [ | ||
// TODO: should remove it and add only in `created` if it's empty | ||
{ | ||
@@ -78,4 +76,11 @@ // Path prefix to this route | ||
// If set to false, error responses with a status code indicating a client error will not be logged | ||
log4XXResponses: true | ||
// If set to true, it will log 4xx client errors, as well | ||
log4XXResponses: false, | ||
// Use HTTP2 server (experimental) | ||
http2: false, | ||
// Optimize route order | ||
optimizeOrder: true, | ||
}, | ||
@@ -87,10 +92,10 @@ | ||
created() { | ||
if (!this.settings.middleware) { | ||
// Create HTTP or HTTPS server (if not running as middleware) | ||
if (this.settings.https && this.settings.https.key && this.settings.https.cert) { | ||
this.server = https.createServer(this.settings.https, this.httpHandler); | ||
this.isHTTPS = true; | ||
if (this.settings.server !== false) { | ||
if (_.isObject(this.settings.server)) { | ||
// Use an existing server instance | ||
this.server = this.settings.server; | ||
} else { | ||
this.server = http.createServer(this.httpHandler); | ||
this.isHTTPS = false; | ||
// Create a new HTTP/HTTPS/HTTP2 server instance | ||
this.createServer(); | ||
} | ||
@@ -102,2 +107,4 @@ | ||
}); | ||
this.logger.info("API Gateway server created."); | ||
} | ||
@@ -112,11 +119,12 @@ | ||
// Process routes | ||
if (Array.isArray(this.settings.routes)) { | ||
this.routes = this.settings.routes.map(route => this.createRoute(route)); | ||
} | ||
this.logger.info("API Gateway created!"); | ||
this.routes = []; | ||
if (Array.isArray(this.settings.routes)) | ||
this.settings.routes.forEach(route => this.addRoute(route)); | ||
}, | ||
actions: { | ||
// REST request handler | ||
/** | ||
* REST request handler | ||
*/ | ||
rest: { | ||
@@ -179,2 +187,31 @@ visibility: "private", | ||
/** | ||
* 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 ? this.tryLoadHTTP2Lib().createSecureServer(this.settings.https, this.httpHandler) : https.createServer(this.settings.https, this.httpHandler); | ||
this.isHTTPS = true; | ||
} else { | ||
this.server = this.settings.http2 ? this.tryLoadHTTP2Lib().createServer(this.httpHandler) : http.createServer(this.httpHandler); | ||
this.isHTTPS = false; | ||
} | ||
}, | ||
/** | ||
* Try to require HTTP2 servers | ||
*/ | ||
tryLoadHTTP2Lib() { | ||
/* istanbul ignore next */ | ||
try { | ||
return require("http2"); | ||
} catch (err) { | ||
/* istanbul ignore next */ | ||
this.broker.fatal("HTTP2 server is not available. (>= Node 8.8.1)"); | ||
} | ||
}, | ||
/** | ||
* HTTP request handler. It is called from native NodeJS HTTP server. | ||
@@ -228,3 +265,4 @@ * | ||
/** | ||
* Handle request in the matched route | ||
* Handle request in the matched route. | ||
* | ||
* @param {Context} ctx | ||
@@ -243,3 +281,3 @@ * @param {Route} route | ||
return this.composeThen(req, res, ...route.middlewares) | ||
return composeThen(req, res, ...route.middlewares) | ||
.then(() => { | ||
@@ -287,4 +325,4 @@ let params = {}; | ||
if (found) { | ||
let alias = found.alias; | ||
this.logger.debug(` Alias: ${req.method} ${urlPath} -> ${alias.action}`); | ||
const alias = found.alias; | ||
this.logger.debug(" Alias:", alias.toString()); | ||
@@ -321,3 +359,3 @@ if (route.opts.mergeParams === false) { | ||
return this.aliasHandler(req, res, { action }); | ||
return this.aliasHandler(req, res, { action, _generated: true }); // To handle #27 | ||
}) | ||
@@ -381,4 +419,8 @@ .then(resolve) | ||
const endpoint = this.broker.findNextActionEndpoint(alias.action); | ||
if (endpoint instanceof Error) | ||
if (endpoint instanceof Error) { | ||
// TODO: #27 | ||
// if (alias._generated && endpoint instanceof ServiceNotFoundError) | ||
// throw 503 - Service unavailable | ||
throw endpoint; | ||
} | ||
@@ -433,3 +475,3 @@ if (endpoint.action.publish === false) { | ||
// Call custom alias handler | ||
this.logger.info(` Call custom function in '${req.$alias.method} ${req.$alias.path}' alias`); | ||
this.logger.info(` Call custom function in '${alias.toString()}' alias`); | ||
return new this.Promise((resolve, reject) => { | ||
@@ -444,9 +486,9 @@ alias.handler.call(this, req, res, err => { | ||
if (alias.action) | ||
return this.callAction(route, alias.action, req, res, req.$params); | ||
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", { alias }); | ||
throw new MoleculerServerError("No alias handler", 500, "NO_ALIAS_HANDLER", { path: req.originalUrl }); | ||
}); | ||
} else if (alias.action) { | ||
return this.callAction(route, alias.action, req, res, req.$params); | ||
return this.callAction(route, alias.action, req, res, alias.type == "stream" ? req : req.$params); | ||
} | ||
@@ -487,8 +529,5 @@ }); | ||
// Process the response | ||
// Post-process the response | ||
.then(data => { | ||
//if (ctx.cachedResult) | ||
// res.setHeader("X-From-Cache", "true"); | ||
// onAfterCall handling | ||
@@ -503,3 +542,3 @@ if (route.onAfterCall) | ||
.then(data => { | ||
this.sendResponse(ctx, route, req, res, data, req.$endpoint.action); | ||
this.sendResponse(req, res, data, req.$endpoint.action); | ||
@@ -513,2 +552,3 @@ this.logResponse(req, res, data); | ||
.catch(err => { | ||
/* istanbul ignore next */ | ||
if (!err) | ||
@@ -524,4 +564,2 @@ return; | ||
* | ||
* @param {Context} ctx | ||
* @param {Object} route | ||
* @param {HttpIncomingMessage} req | ||
@@ -532,4 +570,7 @@ * @param {HttpResponse} res | ||
*/ | ||
sendResponse(ctx, route, req, res, data, action = {}) { | ||
sendResponse(req, res, data, action) { | ||
const ctx = req.$ctx; | ||
const route = req.$route; | ||
/* istanbul ignore next */ | ||
if (res.headersSent) { | ||
@@ -540,2 +581,3 @@ this.logger.warn("Headers have already sent"); | ||
/* istanbul ignore next */ | ||
if (!res.statusCode) | ||
@@ -553,4 +595,5 @@ res.statusCode = 200; | ||
// Redirect | ||
if (res.statusCode >= 300 && res.statusCode < 400) { | ||
if (res.statusCode >= 300 && res.statusCode < 400 && res.statusCode !== 304) { | ||
const location = ctx.meta.$location; | ||
/* istanbul ignore next */ | ||
if (!location) | ||
@@ -563,9 +606,12 @@ this.logger.warn(`The 'ctx.meta.$location' is missing for status code ${res.statusCode}!`); | ||
// Override responseType by action (Deprecated) | ||
let responseType = action.responseType; | ||
if (responseType) { | ||
let responseType; | ||
/* istanbul ignore next */ | ||
if (action && action.responseType) { | ||
deprecate("The 'responseType' action property has been deprecated. Use 'ctx.meta.$responseType' instead"); | ||
responseType = action.responseType; | ||
} | ||
// Custom headers (Deprecated) | ||
if (action.responseHeaders) { | ||
/* istanbul ignore next */ | ||
if (action && action.responseHeaders) { | ||
deprecate("The 'responseHeaders' action property has been deprecated. Use 'ctx.meta.$responseHeaders' instead"); | ||
@@ -593,6 +639,6 @@ Object.keys(action.responseHeaders).forEach(key => { | ||
} | ||
if (data == null) | ||
return res.end(); | ||
let chunk; | ||
// Buffer | ||
@@ -602,3 +648,3 @@ if (Buffer.isBuffer(data)) { | ||
res.setHeader("Content-Length", data.length); | ||
res.end(data); | ||
chunk = data; | ||
} | ||
@@ -610,3 +656,3 @@ // Buffer from Object | ||
res.setHeader("Content-Length", buf.length); | ||
res.end(buf); | ||
chunk = buf; | ||
} | ||
@@ -616,22 +662,51 @@ // Stream | ||
res.setHeader("Content-Type", responseType || "application/octet-stream"); | ||
data.pipe(res); | ||
chunk = data; | ||
} | ||
// Object or Array | ||
// Object or Array (stringify) | ||
else if (_.isObject(data) || Array.isArray(data)) { | ||
res.setHeader("Content-Type", responseType || "application/json; charset=utf-8"); | ||
res.end(JSON.stringify(data)); | ||
chunk = JSON.stringify(data); | ||
} | ||
// Other | ||
// Other (stringify or raw text) | ||
else { | ||
if (!responseType) { | ||
res.setHeader("Content-Type", "application/json; charset=utf-8"); | ||
res.end(JSON.stringify(data)); | ||
chunk = JSON.stringify(data); | ||
} else { | ||
res.setHeader("Content-Type", responseType); | ||
if (_.isString(data)) | ||
res.end(data); | ||
chunk = data; | ||
else | ||
res.end(data.toString()); | ||
chunk = data.toString(); | ||
} | ||
} | ||
// Auto generate & add ETag | ||
if(route.etag && chunk && !res.getHeader("ETag") && !isReadableStream(chunk)) { | ||
res.setHeader("ETag", generateETag.call(this, chunk, route.etag)); | ||
} | ||
// Freshness | ||
if (isFresh(req, res)) | ||
res.statusCode = 304; | ||
if (res.statusCode === 204 || res.statusCode === 304) { | ||
res.removeHeader("Content-Type"); | ||
res.removeHeader("Content-Length"); | ||
res.removeHeader("Transfer-Encoding"); | ||
chunk = ""; | ||
} | ||
if (req.method === "HEAD") { | ||
// skip body for HEAD | ||
res.end(); | ||
} else { | ||
// respond | ||
if (isReadableStream(data)) { //Stream response | ||
data.pipe(res); | ||
} else { | ||
res.end(chunk); | ||
} | ||
} | ||
}, | ||
@@ -649,56 +724,2 @@ | ||
/** | ||
* Compose middlewares | ||
* | ||
* @param {...Function} mws | ||
*/ | ||
compose(...mws) { | ||
return (req, res, done) => { | ||
const next = (i, err) => { | ||
if (i >= mws.length) { | ||
if (_.isFunction(done)) | ||
return done.call(this, err); | ||
return; | ||
} | ||
if (err) { | ||
// Call only error middlewares (err, req, res, next) | ||
if (mws[i].length == 4) | ||
mws[i].call(this, err, req, res, err => next(i + 1, err)); | ||
else | ||
next(i + 1, err); | ||
} else { | ||
if (mws[i].length < 4) | ||
mws[i].call(this, req, res, err => next(i + 1, err)); | ||
else | ||
next(i + 1); | ||
} | ||
}; | ||
return next(0); | ||
}; | ||
}, | ||
/** | ||
* Compose middlewares and return Promise | ||
* @param {...Function} mws | ||
* @returns {Promise} | ||
*/ | ||
composeThen(req, res, ...mws) { | ||
return new this.Promise((resolve, reject) => { | ||
this.compose(...mws)(req, res, err => { | ||
if (err) { | ||
if (err instanceof MoleculerError) | ||
return reject(err); | ||
if (err instanceof Error) | ||
return reject(new MoleculerError(err.message, err.code || err.status, err.type)); | ||
return reject(new MoleculerError(err)); | ||
} | ||
resolve(); | ||
}); | ||
}); | ||
}, | ||
/** | ||
* Send 404 response | ||
@@ -736,7 +757,9 @@ * | ||
/* istanbul ignore next */ | ||
if (res.headersSent) { | ||
this.logger.warn("Headers have already sent"); | ||
this.logger.warn("Headers have already sent", req.url, err); | ||
return; | ||
} | ||
/* istanbul ignore next */ | ||
if (!err || !(err instanceof Error)) { | ||
@@ -750,2 +773,3 @@ res.writeHead(500); | ||
/* istanbul ignore next */ | ||
if (!(err instanceof MoleculerError)) { | ||
@@ -799,3 +823,3 @@ const e = err; | ||
} | ||
return {query, url}; | ||
return { query, url }; | ||
}, | ||
@@ -827,2 +851,4 @@ | ||
return chalk.green.bold(code); | ||
/* istanbul ignore next */ | ||
return code; | ||
@@ -850,2 +876,3 @@ }, | ||
/* istanbul ignore next */ | ||
if (this.settings.logResponseData && this.settings.logResponseData in this.logger) { | ||
@@ -871,2 +898,3 @@ this.logger[this.settings.logResponseData](" Data:", data); | ||
// Based on: https://github.com/hapijs/hapi | ||
// eslint-disable-next-line | ||
const wildcard = new RegExp(`^${_.escapeRegExp(settings).replace(/\\\*/g, ".*").replace(/\\\?/g, ".")}$`); | ||
@@ -897,2 +925,4 @@ return origin.match(wildcard); | ||
writeCorsHeaders(route, req, res, isPreFlight) { | ||
/* istanbul ignore next */ | ||
if (!route.cors) return; | ||
@@ -984,9 +1014,6 @@ | ||
const alias = route.aliases[i]; | ||
if (alias.method === "*" || alias.method === method) { | ||
const res = alias.match(url); | ||
if (res) { | ||
return { | ||
alias, | ||
params: res | ||
}; | ||
if (alias.isMethod(method)) { | ||
const params = alias.match(url); | ||
if (params) { | ||
return { alias, params }; | ||
} | ||
@@ -999,2 +1026,46 @@ } | ||
/** | ||
* 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 => r.path == route.path); | ||
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) | ||
this.routes.splice(idx, 1); | ||
}, | ||
/** | ||
* Optimize route order by route path depth | ||
*/ | ||
optimizeRouteOrder() { | ||
this.routes.sort((a,b) => addSlashes(b.path).split("/").length - addSlashes(a.path).split("/").length); | ||
this.logger.debug("Optimized path order: ", this.routes.map(r => r.path)); | ||
}, | ||
/** | ||
* Create route object from options | ||
@@ -1039,2 +1110,5 @@ * | ||
// ETag | ||
route.etag = opts.etag != null ? opts.etag : this.settings.etag; | ||
// Middlewares | ||
@@ -1105,96 +1179,149 @@ let mw = []; | ||
const globalPath = this.settings.path && this.settings.path != "/" ? this.settings.path : ""; | ||
route.path = globalPath + (opts.path || ""); | ||
route.path = route.path || "/"; | ||
route.path = addSlashes(globalPath) + (opts.path || ""); | ||
route.path = normalizePath(route.path); | ||
// Helper for aliased routes | ||
const createAlias = (matchPath, action) => { | ||
let method = "*"; | ||
if (matchPath.indexOf(" ") !== -1) { | ||
// Create aliases | ||
this.createRouteAliases(route, opts.aliases); | ||
// Set alias mapping policy | ||
route.mappingPolicy = opts.mappingPolicy || MAPPING_POLICY_ALL; | ||
this.logger.info(""); | ||
return route; | ||
}, | ||
/** | ||
* Create all aliases for route. | ||
* @param {Object} route | ||
* @param {Object} aliases | ||
*/ | ||
createRouteAliases(route, aliases) { | ||
route.aliases = []; | ||
_.forIn(aliases, (action, matchPath) => { | ||
if (matchPath.startsWith("REST ")) { | ||
const p = matchPath.split(/\s+/); | ||
method = p[0]; | ||
matchPath = p[1]; | ||
} | ||
if (matchPath.startsWith("/")) | ||
matchPath = matchPath.slice(1); | ||
const pathName = p[1]; | ||
let alias; | ||
if (_.isString(action)) | ||
alias = { action }; | ||
else if (_.isFunction(action)) | ||
alias = { handler: action }; | ||
else if (Array.isArray(action)) { | ||
alias = {}; | ||
const mws = _.compact(action.map(mw => { | ||
if (_.isString(mw)) | ||
alias.action = mw; | ||
else if(_.isFunction(mw)) | ||
return mw; | ||
})); | ||
alias.handler = this.compose(...mws); | ||
// Generate RESTful API. More info http://www.restapitutorial.com/ | ||
route.aliases.push( | ||
this.createAlias(route, `GET ${pathName}`, `${action}.list`), | ||
this.createAlias(route, `GET ${pathName}/:id`, `${action}.get`), | ||
this.createAlias(route, `POST ${pathName}`, `${action}.create`), | ||
this.createAlias(route, `PUT ${pathName}/:id`, `${action}.update`), | ||
this.createAlias(route, `PATCH ${pathName}/:id`, `${action}.patch`), | ||
this.createAlias(route, `DELETE ${pathName}/:id`, `${action}.remove`) | ||
); | ||
} else { | ||
alias = action; | ||
route.aliases.push(this.createAlias(route, matchPath, action)); | ||
} | ||
}); | ||
alias.path = matchPath; | ||
alias.method = method; | ||
if (route.opts.autoAliases) { | ||
this.regenerateAutoAliases(route); | ||
} | ||
let keys = []; | ||
alias.re = pathToRegexp(matchPath, keys, {}); // Options: https://github.com/pillarjs/path-to-regexp#usage | ||
this.logger.info(` Alias: ${method} ${route.path + (route.path.endsWith("/") ? "": "/")}${matchPath} -> ${alias.handler != null ? "<Function>" : alias.action}`); | ||
return route.aliases; | ||
}, | ||
alias.match = url => { | ||
const m = alias.re.exec(url); | ||
if (!m) return false; | ||
/** | ||
* Regenerate aliases automatically if service registry has been changed. | ||
* | ||
* @param {Route} route | ||
*/ | ||
regenerateAutoAliases(route) { | ||
this.logger.info(`♻ Generate aliases for '${route.path}' route...`); | ||
const params = {}; | ||
route.aliases = route.aliases.filter(alias => !alias._generated); | ||
let key, param; | ||
for (let i = 0; i < keys.length; i++) { | ||
key = keys[i]; | ||
param = m[i + 1]; | ||
if (!param) continue; | ||
const processedServices = new Set(); | ||
params[key.name] = decodeParam(param); | ||
const services = this.broker.registry.getServiceList({ withActions: true }); | ||
services.forEach(service => { | ||
const serviceName = service.version ? `v${service.version}.${service.name}` : service.name; | ||
const basePath = addSlashes(_.isString(service.settings.rest) ? service.settings.rest : serviceName.replace(/\./g, "/")); | ||
if (key.repeat) | ||
params[key.name] = params[key.name].split(key.delimiter); | ||
} | ||
// Skip multiple instances of services | ||
if (processedServices.has(serviceName)) return; | ||
return params; | ||
}; | ||
_.forIn(service.actions, action => { | ||
if (action.rest) { | ||
let alias = null; | ||
return alias; | ||
}; | ||
// Check visibility | ||
if (action.visibility != null && action.visibility != "published") return; | ||
// Handle aliases | ||
if (opts.aliases && Object.keys(opts.aliases).length > 0) { | ||
route.aliases = []; | ||
_.forIn(opts.aliases, (action, matchPath) => { | ||
if (matchPath.startsWith("REST ")) { | ||
const p = matchPath.split(/\s+/); | ||
const pathName = p[1]; | ||
// Check whitelist | ||
if (route.hasWhitelist && !this.checkWhitelist(route, action.name)) return; | ||
// Generate RESTful API. More info http://www.restapitutorial.com/ | ||
route.aliases.push(createAlias(`GET ${pathName}`, `${action}.list`)); | ||
route.aliases.push(createAlias(`GET ${pathName}/:id`, `${action}.get`)); | ||
route.aliases.push(createAlias(`POST ${pathName}`, `${action}.create`)); | ||
route.aliases.push(createAlias(`PUT ${pathName}/:id`, `${action}.update`)); | ||
route.aliases.push(createAlias(`PATCH ${pathName}/:id`, `${action}.patch`)); | ||
route.aliases.push(createAlias(`DELETE ${pathName}/:id`, `${action}.remove`)); | ||
if (_.isString(action.rest)) { | ||
if (action.rest.indexOf(" ") !== -1) { | ||
// Handle route: "POST /import" | ||
const p = action.rest.split(/\s+/); | ||
alias = { | ||
method: p[0], | ||
path: basePath + p[1] | ||
}; | ||
} else { | ||
// Handle route: "/import". In this case apply to all methods as "* /import" | ||
alias = { | ||
method: "*", | ||
path: basePath + action.rest | ||
}; | ||
} | ||
} else if (action.rest === true) { | ||
// "route: true" is converted to "* {baseName}/{action.rawName}" | ||
alias = { | ||
method: "*", | ||
path: basePath + action.rawName | ||
}; | ||
} else if (_.isObject(action.rest)) { | ||
// Handle route: { method: "POST", route: "/other" } | ||
alias = Object.assign({}, action.rest, { | ||
method: action.rest.method || "*", | ||
path: basePath + action.rest.path ? action.rest.path : action.rawName | ||
}); | ||
} | ||
} else { | ||
route.aliases.push(createAlias(matchPath, action)); | ||
if (alias) { | ||
alias.path = removeTrailingSlashes(normalizePath(alias.path)); | ||
alias._generated = true; | ||
route.aliases.push(this.createAlias(route, alias, action.name)); | ||
} | ||
} | ||
processedServices.add(serviceName); | ||
}); | ||
} | ||
route.mappingPolicy = opts.mappingPolicy || MAPPING_POLICY_ALL; | ||
}); | ||
}, | ||
return route; | ||
/** | ||
* 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.logger.info(" " + alias.toString()); | ||
return alias; | ||
}, | ||
// Regenerate all auto aliases routes | ||
regenerateAllAutoAliases: _.debounce(function() { | ||
/* istanbul ignore next */ | ||
this.routes.forEach(route => route.opts.autoAliases && this.regenerateAutoAliases(route)); | ||
}, 500) | ||
}, | ||
events: { | ||
"$services.changed"() { | ||
this.regenerateAllAutoAliases(); | ||
} | ||
}, | ||
/** | ||
@@ -1204,4 +1331,4 @@ * Service started lifecycle event handler | ||
started() { | ||
if (this.settings.middleware) | ||
return; | ||
if (this.settings.server === false) | ||
return this.Promise.resolve(); | ||
@@ -1225,6 +1352,3 @@ /* istanbul ignore next */ | ||
stopped() { | ||
if (this.settings.middleware) | ||
return; | ||
if (this.server.listening) { | ||
if (this.settings.server !== false && this.server.listening) { | ||
/* istanbul ignore next */ | ||
@@ -1241,2 +1365,4 @@ return new this.Promise((resolve, reject) => { | ||
} | ||
return this.Promise.resolve(); | ||
}, | ||
@@ -1243,0 +1369,0 @@ |
@@ -14,2 +14,3 @@ "use strict"; | ||
item.author = fake.random.number(1, 10); | ||
item.created = item.created.toISOString(); | ||
@@ -28,2 +29,3 @@ rows.push(item); | ||
cache: true, | ||
rest: "GET /", | ||
handler(ctx) { | ||
@@ -39,2 +41,3 @@ return this.rows; | ||
}, | ||
rest: "GET /:id", | ||
handler(ctx) { | ||
@@ -50,2 +53,3 @@ const post = this.findByID(ctx.params.id); | ||
create: { | ||
rest: "POST /", | ||
handler(ctx) { | ||
@@ -61,2 +65,3 @@ this.rows.push(ctx.params); | ||
update: { | ||
rest: "PUT /:id", | ||
handler(ctx) { | ||
@@ -80,2 +85,3 @@ const post = this.findByID(ctx.params.id); | ||
patch: { | ||
rest: "PATCH /:id", | ||
handler(ctx) { | ||
@@ -87,2 +93,3 @@ return this.actions.update(ctx.params, { parentCtx: ctx }); | ||
remove: { | ||
rest: "DELETE /:id", | ||
handler(ctx) { | ||
@@ -89,0 +96,0 @@ this.rows = this.rows.filter(row => row.id != ctx.params.id); |
@@ -12,8 +12,17 @@ "use strict"; | ||
name: "test", | ||
settings: { | ||
rest: "/" | ||
}, | ||
actions: { | ||
hello(ctx) { | ||
return "Hello Moleculer"; | ||
hello: { | ||
rest: "GET /hi", | ||
handler(ctx) { | ||
return "Hello Moleculer"; | ||
} | ||
}, | ||
greeter: { | ||
rest: "/greeter", | ||
params: { | ||
@@ -194,2 +203,16 @@ name: "string" | ||
freshness(ctx){ | ||
ctx.meta.$responseHeaders = { | ||
"Last-Modified": "Mon, 06 Aug 2018 14:23:28 GMT" | ||
}; | ||
return "fresh"; | ||
}, | ||
etag(ctx){ | ||
ctx.meta.$responseHeaders = { | ||
"ETag": "my custom etag" | ||
}; | ||
return {}; | ||
}, | ||
error() { | ||
@@ -196,0 +219,0 @@ throw new MoleculerServerError("I'm dangerous", 500); |
@@ -43,3 +43,3 @@ "use strict"; | ||
expect(err.message).toBe("Unauthorized"); | ||
expect(err.data).toEqual({ a: 5}); | ||
expect(err.data).toEqual({ a: 5 }); | ||
}); | ||
@@ -56,3 +56,3 @@ | ||
expect(err.message).toBe("Forbidden"); | ||
expect(err.data).toEqual({ a: 5}); | ||
expect(err.data).toEqual({ a: 5 }); | ||
}); | ||
@@ -69,3 +69,3 @@ | ||
expect(err.message).toBe("Bad request"); | ||
expect(err.data).toEqual({ a: 5}); | ||
expect(err.data).toEqual({ a: 5 }); | ||
}); | ||
@@ -83,4 +83,4 @@ | ||
expect(err.message).toBe("Rate limit exceeded"); | ||
expect(err.data).toEqual({ a: 5}); | ||
expect(err.data).toEqual({ a: 5 }); | ||
}); | ||
}); |
@@ -21,3 +21,3 @@ "use strict"; | ||
const MockRequest = () => Object.assign(jest.fn(), {headers: {}}); | ||
const MockRequest = () => Object.assign(jest.fn(), { headers: {} }); | ||
@@ -94,3 +94,3 @@ describe("WebGateway", () => { | ||
return handler.bind(context)(req, res, next).then(() => { | ||
expect(context.actions.rest.mock.calls[0]).toEqual([{req, res}, {requestID: "foobar"}]); | ||
expect(context.actions.rest.mock.calls[0]).toEqual([{ req, res }, { requestID: "foobar" }]); | ||
}); | ||
@@ -110,3 +110,3 @@ }); | ||
return handler.bind(context)(req, res, next).then(() => { | ||
expect(context.actions.rest.mock.calls[0]).toEqual([{req, res}, {requestID: "barfoo"}]); | ||
expect(context.actions.rest.mock.calls[0]).toEqual([{ req, res }, { requestID: "barfoo" }]); | ||
}); | ||
@@ -121,3 +121,3 @@ }); | ||
const context = MockContext(); | ||
context.actions.rest.mockReturnValueOnce(Promise.resolve({foo: "bar"})); | ||
context.actions.rest.mockReturnValueOnce(Promise.resolve({ foo: "bar" })); | ||
@@ -182,3 +182,3 @@ return handler.bind(context)(req, res, next).then(result => { | ||
let error = new Error("Something went wrong while invoking the rest action"); | ||
error.code = 419; | ||
error.code = 503; | ||
context.actions.rest.mockReturnValueOnce(Promise.reject(error)); | ||
@@ -185,0 +185,0 @@ |
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
442344
166.8%25
13.64%5387
23.1%95
3.26%12
33.33%27
8%4
33.33%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added