moleculer-web
Advanced tools
<a name="0.6.0"></a> | ||
# 0.6.0 (2017-xx-xx) | ||
## Changes | ||
## Breaking changes | ||
### Alias custom function arguments is changed | ||
The `route` first argument is removed. The new signature of function is `function(req, res) {}`. To access to route use the `req.$route` property. | ||
However you can use an array of `Function` for aliases. With it you can call middlewares. In this case the third argument is `next`. I.e.: `function(req, res, next) {}`. | ||
## Other changes | ||
- better error handling. Always returns with JSON error response. | ||
@@ -9,7 +15,9 @@ - The charset is `UTF-8` for `application/json` responses. | ||
- `logResponseData` setting to log the response data. Use log level value i.e. `"debug"`, `"info"` or `null` to disable. | ||
- alias function has a new 4th parameter, the `params`. | ||
- the `req.service` is pointed to the service instance. | ||
- `req.$service` & `res.$service` is pointed to the service instance. | ||
- `req.$route` & `res.$route` is pointed to the route definition. | ||
- `req.$params` is pointed to the resolved parameters (from query string & post body) | ||
- `req.$alias` is pointed to the alias definition. | ||
## Middlewares | ||
Support middlewares in routes. | ||
Support middlewares in routes & aliases | ||
@@ -20,2 +28,7 @@ ```js | ||
settings: { | ||
// Global middlewares. Applied to all routes. | ||
use: [ | ||
cookieParser() | ||
], | ||
routes: [ | ||
@@ -25,7 +38,16 @@ { | ||
// Middlewares | ||
// Route-level middlewares | ||
use: [ | ||
compression(), | ||
serveStatic(path.join(__dirname, "public")) | ||
] | ||
], | ||
aliases: { | ||
"GET /secret": [ | ||
// Alias-level middlewares | ||
auth.isAuthenticated(), | ||
auth.hasRole("admin"), | ||
"top.secret" // Call the `top.secret` action | ||
] | ||
} | ||
} | ||
@@ -32,0 +54,0 @@ ] |
{ | ||
"name": "moleculer-web", | ||
"version": "0.6.0-beta3", | ||
"version": "0.6.0-beta4", | ||
"description": "Official API Gateway service for Moleculer framework", | ||
@@ -58,3 +58,2 @@ "main": "index.js", | ||
"body-parser": "1.18.2", | ||
"depd": "1.1.1", | ||
"es6-error": "4.0.2", | ||
@@ -61,0 +60,0 @@ "isstream": "0.1.2", |
318
src/index.js
@@ -20,7 +20,5 @@ /* | ||
const deprecate = require("depd")("moleculer-web"); | ||
const { Context } = require("moleculer"); | ||
const { MoleculerError, ServiceNotFoundError } = require("moleculer").Errors; | ||
const { InvalidRequestBodyError, BadRequestError, RateLimitExceeded, ERR_UNABLE_DECODE_PARAM } = require("./errors"); | ||
const { MoleculerError, MoleculerServerError, ServiceNotFoundError } = require("moleculer").Errors; | ||
const { BadRequestError, RateLimitExceeded, ERR_UNABLE_DECODE_PARAM } = require("./errors"); | ||
@@ -120,2 +118,23 @@ const MemoryStore = require("./memory-store"); | ||
/** | ||
* Compose middlewares | ||
* | ||
* @param {...Function} mws | ||
*/ | ||
compose(...mws) { | ||
return (req, res, done) => { | ||
const next = (i, err) => { | ||
if (i >= mws.length || err) { | ||
if (_.isFunction(done)) | ||
return done.call(this, err); | ||
return; | ||
} | ||
mws[i].call(this, req, res, err => next(i + 1, err)); | ||
}; | ||
return next(0); | ||
}; | ||
}, | ||
/** | ||
* Create route object from options | ||
@@ -154,20 +173,14 @@ * | ||
// Middlewares | ||
if (opts.use && Array.isArray(opts.use) && opts.use.length > 0) { | ||
let mw = []; | ||
if (this.settings.use && Array.isArray(this.settings.use) && this.settings.use.length > 0) | ||
mw.push(...this.settings.use); | ||
route.middlewares.push(...opts.use); | ||
if (opts.use && Array.isArray(opts.use) && opts.use.length > 0) | ||
mw.push(...opts.use); | ||
this.logger.info(` Registered ${opts.use.length.length} middlewares.`); | ||
if (mw.length > 0) { | ||
route.middlewares.push(...mw); | ||
this.logger.info(` Registered ${mw.length} middlewares.`); | ||
} | ||
route.callMiddlewares = (req, res, next) => { | ||
const nextFn = (i, err) => { | ||
if (i >= route.middlewares.length || err) | ||
return next.call(this, req, res, err); | ||
route.middlewares[i].call(this, req, res, err => nextFn(i + 1, err)); | ||
}; | ||
return nextFn(0); | ||
}; | ||
// CORS | ||
@@ -224,3 +237,3 @@ if (this.settings.cors || opts.cors) { | ||
// Helper for aliased routes | ||
const createAliasedRoute = (action, matchPath) => { | ||
const createAlias = (matchPath, action) => { | ||
let method = "*"; | ||
@@ -235,31 +248,49 @@ if (matchPath.indexOf(" ") !== -1) { | ||
let alias; | ||
if (_.isString(action)) | ||
alias = { action }; | ||
else if (_.isFunction(action)) | ||
alias = { handler: action }; | ||
else if (Array.isArray(action)) { | ||
let mws = action.map(mw => { | ||
if (_.isString(mw)) | ||
return (req, res) => this.preActionCall(route, req, res, mw); | ||
else if(_.isFunction(mw)) | ||
return mw; | ||
}); | ||
alias = { handler: this.compose(...mws) }; | ||
} else { | ||
alias = action; | ||
} | ||
alias.path = matchPath; | ||
alias.method = method; | ||
let keys = []; | ||
const re = pathToRegexp(matchPath, keys, {}); // Options: https://github.com/pillarjs/path-to-regexp#usage | ||
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} -> ${_.isFunction(action) ? "<Function>" : action}`); | ||
return { | ||
action, | ||
method, | ||
re, | ||
match: url => { | ||
const m = re.exec(url); | ||
if (!m) return false; | ||
this.logger.info(` Alias: ${method} ${route.path + (route.path.endsWith("/") ? "": "/")}${matchPath} -> ${alias.handler != null ? "<Function>" : alias.action}`); | ||
const params = {}; | ||
alias.match = url => { | ||
const m = alias.re.exec(url); | ||
if (!m) return false; | ||
let key, param; | ||
for (let i = 0; i < keys.length; i++) { | ||
key = keys[i]; | ||
param = m[i + 1]; | ||
if (!param) continue; | ||
const params = {}; | ||
params[key.name] = decodeParam(param); | ||
let key, param; | ||
for (let i = 0; i < keys.length; i++) { | ||
key = keys[i]; | ||
param = m[i + 1]; | ||
if (!param) continue; | ||
if (key.repeat) | ||
params[key.name] = params[key.name].split(key.delimiter); | ||
} | ||
params[key.name] = decodeParam(param); | ||
return params; | ||
if (key.repeat) | ||
params[key.name] = params[key.name].split(key.delimiter); | ||
} | ||
return params; | ||
}; | ||
return alias; | ||
}; | ||
@@ -276,11 +307,11 @@ | ||
// Generate RESTful API. More info http://www.restapitutorial.com/ | ||
route.aliases.push(createAliasedRoute(`${action}.list`, `GET ${pathName}`)); | ||
route.aliases.push(createAliasedRoute(`${action}.get`, `GET ${pathName}/:id`)); | ||
route.aliases.push(createAliasedRoute(`${action}.create`, `POST ${pathName}`)); | ||
route.aliases.push(createAliasedRoute(`${action}.update`, `PUT ${pathName}/:id`)); | ||
//route.aliases.push(createAliasedRoute(`${action}.update`, `PATCH ${pathName}/:id`)); | ||
route.aliases.push(createAliasedRoute(`${action}.remove`, `DELETE ${pathName}/:id`)); | ||
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}.update`)); | ||
route.aliases.push(createAlias(`DELETE ${pathName}/:id`, `${action}.remove`)); | ||
} else { | ||
route.aliases.push(createAliasedRoute(action, matchPath)); | ||
route.aliases.push(createAlias(matchPath, action)); | ||
} | ||
@@ -299,7 +330,6 @@ }); | ||
* @param {HttpResponse} res | ||
* @param {Function} next - `next` callback in middleware mode | ||
*/ | ||
send404(req, res, next) { | ||
if (next) | ||
return next(); | ||
send404(req, res) { | ||
if (req.$next) | ||
return req.$next(); | ||
@@ -314,7 +344,6 @@ this.sendError(req, res, new MoleculerError("Not found", 404)); | ||
* @param {Error} err | ||
* @param {Function} next - `next` callback in middleware mode | ||
*/ | ||
sendError(req, res, err, next) { | ||
if (next) | ||
return next(err); | ||
sendError(req, res, err) { | ||
if (req.$next) | ||
return req.$next(err); | ||
@@ -349,3 +378,4 @@ if (!err || !(err instanceof Error)) { | ||
res.writeHead(code, { | ||
"Location": url | ||
"Location": url, | ||
"Content-Length": "0" | ||
}); | ||
@@ -362,3 +392,3 @@ res.end(); | ||
*/ | ||
processQueryString(req) { | ||
parseQueryString(req) { | ||
// Split URL & query params | ||
@@ -384,3 +414,6 @@ let url = req.url; | ||
httpHandler(req, res, next) { | ||
req.service = this; // pointer to this service | ||
req.$service = this; // pointer to this service | ||
res.$service = this; // pointer to this service | ||
req.$next = next; | ||
req.locals = req.locals || {}; | ||
@@ -391,7 +424,8 @@ this.logRequest(req); | ||
// Split URL & query params | ||
let {query, url} = this.processQueryString(req); | ||
let parsed = this.parseQueryString(req); | ||
let url = parsed.url; | ||
if (!req.query) | ||
req.query = query; | ||
req.query = parsed.query; | ||
let params = Object.assign({}, query); | ||
let params = {}; | ||
@@ -409,18 +443,72 @@ // Trim trailing slash | ||
req.route = route; | ||
// Pointer to the matched route | ||
req.$route = route; | ||
res.$route = route; | ||
// Call middlewares | ||
if (route.middlewares.length > 0) { | ||
route.callMiddlewares(req, res, (req, res, err) => { | ||
if (err) { | ||
const error = new MoleculerError(err.message, err.status, err.type); | ||
this.logger.error("Middleware error!", error); | ||
return this.sendError(req, res, error, next); | ||
this.compose(...route.middlewares)(req, res, err => { | ||
if (err) { | ||
const error = new MoleculerError(err.message, err.status, err.type); | ||
this.logger.error("Middleware error!", error); | ||
return this.sendError(req, res, error); | ||
} | ||
// Merge params | ||
const body = _.isObject(req.body) ? req.body : {}; | ||
Object.assign(params, body, req.query); | ||
req.$params = params; | ||
// Resolve action name | ||
let urlPath = url.slice(route.path.length); | ||
if (urlPath.startsWith("/")) | ||
urlPath = urlPath.slice(1); | ||
urlPath = urlPath.replace(/~/, "$"); | ||
let actionName = urlPath; | ||
// Resolve aliases | ||
if (route.aliases && route.aliases.length > 0) { | ||
const found = this.resolveAlias(route, urlPath, req.method); | ||
if (found) { | ||
let alias = found.alias; | ||
this.logger.debug(` Alias: ${req.method} ${urlPath} -> ${alias.action}`); | ||
Object.assign(params, found.params); | ||
req.$alias = alias; | ||
// Custom Action handler | ||
if (alias.handler) { | ||
return alias.handler.call(this, req, res, err => { | ||
if (err) { | ||
const error = new MoleculerError(err.message, err.status, err.type); | ||
this.logger.error("Alias middleware error!", error); | ||
return this.sendError(req, res, error); | ||
} | ||
if (req.$next) | ||
return req.$next(); | ||
// If it is reached, there is no real handler for this alias. | ||
const error = new MoleculerServerError("No alias handler", 500); | ||
this.logger.error(error); | ||
return this.sendError(req, res, error); | ||
}); | ||
} | ||
actionName = alias.action; | ||
} else if (route.mappingPolicy == MAPPING_POLICY_RESTRICT) { | ||
// Blocking direct access | ||
return this.send404(req, res); | ||
} | ||
this.routeHandler(route, req, res, url, params, next); | ||
}); | ||
} else { | ||
this.routeHandler(route, req, res, url, params, next); | ||
} | ||
} | ||
actionName = actionName.replace(/\//g, "."); | ||
if (route.opts.camelCaseNames) { | ||
actionName = actionName.split(".").map(_.camelCase).join("."); | ||
} | ||
this.preActionCall(route, req, res, actionName); | ||
}); | ||
return; | ||
@@ -435,3 +523,3 @@ } | ||
this.logger.debug(err); | ||
this.send404(req, res, next); | ||
this.send404(req, res); | ||
}); | ||
@@ -442,7 +530,7 @@ return; | ||
// If no route, send 404 | ||
this.send404(req, res, next); | ||
this.send404(req, res); | ||
} catch(err) { | ||
this.logger.error("Handler error!", err); | ||
return this.sendError(req, res, err, next); | ||
return this.sendError(req, res, err); | ||
} | ||
@@ -453,3 +541,2 @@ }, | ||
* Route handler. | ||
* - resolve aliases | ||
* - check whitelist | ||
@@ -462,39 +549,7 @@ * - CORS | ||
* @param {HttpResponse} res | ||
* @param {String} url | ||
* @param {Object} params | ||
* @param {Function} next Call next middleware (for Express) | ||
* @param {String} actionName | ||
* @returns | ||
*/ | ||
routeHandler(route, req, res, url, params, next) { | ||
// Resolve action name | ||
let urlPath = url.slice(route.path.length); | ||
if (urlPath.startsWith("/")) | ||
urlPath = urlPath.slice(1); | ||
preActionCall(route, req, res, actionName) { | ||
urlPath = urlPath.replace(/~/, "$"); | ||
let actionName = urlPath; | ||
// Resolve aliases | ||
if (route.aliases && route.aliases.length > 0) { | ||
const alias = this.resolveAlias(route, urlPath, req.method); | ||
if (alias) { | ||
this.logger.debug(` Alias: ${req.method} ${urlPath} -> ${alias.action}`); | ||
actionName = alias.action; | ||
Object.assign(params, alias.params); | ||
// Custom Action handler | ||
if (_.isFunction(alias.action)) { | ||
return alias.action.call(this, route, req, res, params); | ||
} | ||
} else if (route.mappingPolicy == MAPPING_POLICY_RESTRICT) { | ||
// Blocking direct access | ||
return this.send404(req, res, next); | ||
} | ||
} | ||
actionName = actionName.replace(/\//g, "."); | ||
if (route.opts.camelCaseNames) { | ||
actionName = actionName.split(".").map(part => _.camelCase(part)).join("."); | ||
} | ||
// Whitelist check | ||
@@ -504,3 +559,3 @@ if (route.hasWhitelist) { | ||
this.logger.debug(` The '${actionName}' action is not in the whitelist!`); | ||
return this.sendError(req, res, new ServiceNotFoundError(actionName), next); | ||
return this.sendError(req, res, new ServiceNotFoundError(actionName)); | ||
} | ||
@@ -515,15 +570,13 @@ } | ||
const key = opts.key(req); | ||
if (!key) | ||
/* istanbul ignore next */ | ||
return; | ||
const remaining = opts.limit - 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 (key) { | ||
const remaining = opts.limit - 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) { | ||
return this.sendError(req, res, new RateLimitExceeded()); | ||
} | ||
} | ||
if (remaining < 0) { | ||
return this.sendError(req, res, new RateLimitExceeded(), next); | ||
} | ||
} | ||
@@ -551,8 +604,4 @@ | ||
// Merge params | ||
const body = _.isObject(req.body) ? req.body : {}; | ||
params = Object.assign({}, body, params); | ||
// Call the action | ||
return this.callAction(route, actionName, req, res, params); | ||
return this.callAction(route, actionName, req, res, req.$params); | ||
}, | ||
@@ -612,2 +661,12 @@ | ||
// Pass the `req` & `res` vars to ctx.params. | ||
if (req.$alias && req.$alias.passReqResToParams) { | ||
if (endpoint.local) { | ||
params.$req = req; | ||
params.$res = res; | ||
} else { | ||
this.logger.warn("Don't use the `passReqResToParams` option in aliases if you call a remote service."); | ||
} | ||
} | ||
// Create a new context to wrap the request | ||
@@ -617,2 +676,3 @@ const ctx = Context.create(this.broker, restAction, this.broker.nodeID, params, route.callOptions || {}); | ||
return ctx; | ||
@@ -897,3 +957,3 @@ }) | ||
return { | ||
action: alias.action, | ||
alias, | ||
params: res | ||
@@ -900,0 +960,0 @@ }; |
@@ -604,3 +604,3 @@ "use strict"; | ||
describe("Test alias", () => { | ||
describe("Test aliases", () => { | ||
let broker; | ||
@@ -610,6 +610,21 @@ let service; | ||
let customAlias = jest.fn((route, req, res, params) => { | ||
res.end(`Custom Alias by ${params.name}`); | ||
let customAlias = jest.fn((req, res) => { | ||
expect(req.$route).toBeDefined(); | ||
expect(req.$service).toBe(service); | ||
expect(req.$params).toEqual({ | ||
name: "Ben" | ||
}); | ||
expect(res.$route).toBeDefined(); | ||
expect(res.$service).toBe(service); | ||
res.end(`Custom Alias by ${req.$params.name}`); | ||
}); | ||
let customMiddlewares = [ | ||
jest.fn((req, res, next) => next()), | ||
jest.fn((req, res, next) => next()), | ||
"test.greeter" | ||
]; | ||
beforeAll(() => { | ||
@@ -624,6 +639,13 @@ [ broker, service, server] = setup({ | ||
"GET greeter/:name": "test.greeter", | ||
"POST greeting/:name": "test.greeter", | ||
"opt-test/:name?": "test.echo", | ||
"/repeat-test/:args*": "test.echo", | ||
"GET /": "test.hello", | ||
"GET custom": customAlias | ||
"GET custom": customAlias, | ||
"GET /middleware": customMiddlewares, | ||
"GET /wrong-middleware": [customMiddlewares[0], customMiddlewares[1]], | ||
"GET reqres": { | ||
action: "test.reqres", | ||
passReqResToParams: true | ||
}, | ||
} | ||
@@ -728,2 +750,14 @@ }] | ||
it("POST /api/greeting/Norbert", () => { | ||
return request(server) | ||
.post("/api/greeting/Norbert") | ||
.query({ name: "John" }) | ||
.send({ name: "Adam" }) | ||
.expect(200) | ||
.expect("Content-Type", "application/json; charset=utf-8") | ||
.then(res => { | ||
expect(res.body).toBe("Hello Norbert"); | ||
}); | ||
}); | ||
it("GET opt-test/:name? with name", () => { | ||
@@ -781,5 +815,56 @@ return request(server) | ||
expect(customAlias).toHaveBeenCalledTimes(1); | ||
expect(customAlias).toHaveBeenCalledWith(service.routes[0], jasmine.any(http.IncomingMessage), jasmine.any(http.ServerResponse), { name: "Ben" }); | ||
expect(customAlias).toHaveBeenCalledWith(jasmine.any(http.IncomingMessage), jasmine.any(http.ServerResponse), jasmine.any(Function)); | ||
}); | ||
}); | ||
it("GET /api/middleware", () => { | ||
return request(server) | ||
.get("/api/middleware") | ||
.query({ name: "Ben" }) | ||
.expect(200) | ||
.then(res => { | ||
expect(res.body).toBe("Hello Ben"); | ||
expect(customMiddlewares[0]).toHaveBeenCalledTimes(1); | ||
expect(customMiddlewares[0]).toHaveBeenCalledWith(jasmine.any(http.IncomingMessage), jasmine.any(http.ServerResponse), jasmine.any(Function)); | ||
expect(customMiddlewares[1]).toHaveBeenCalledTimes(1); | ||
expect(customMiddlewares[1]).toHaveBeenCalledWith(jasmine.any(http.IncomingMessage), jasmine.any(http.ServerResponse), jasmine.any(Function)); | ||
}); | ||
}); | ||
it("GET /api/wrong-middleware", () => { | ||
customMiddlewares[0].mockClear(); | ||
customMiddlewares[1].mockClear(); | ||
return request(server) | ||
.get("/api/wrong-middleware") | ||
.expect(500) | ||
.then(res => { | ||
expect(res.body).toEqual({ | ||
"name": "MoleculerServerError", | ||
"message": "No alias handler", | ||
"code": 500, | ||
}); | ||
expect(customMiddlewares[0]).toHaveBeenCalledTimes(1); | ||
expect(customMiddlewares[0]).toHaveBeenCalledWith(jasmine.any(http.IncomingMessage), jasmine.any(http.ServerResponse), jasmine.any(Function)); | ||
expect(customMiddlewares[1]).toHaveBeenCalledTimes(1); | ||
expect(customMiddlewares[1]).toHaveBeenCalledWith(jasmine.any(http.IncomingMessage), jasmine.any(http.ServerResponse), jasmine.any(Function)); | ||
}); | ||
}); | ||
it("GET /api/reqres with name", () => { | ||
return request(server) | ||
.get("/api/reqres") | ||
.expect(200) | ||
.expect("Content-Type", "application/json; charset=utf-8") | ||
.then(res => { | ||
expect(res.body).toEqual({ | ||
hasReq: true, | ||
hasRes: true | ||
}); | ||
}); | ||
}); | ||
}); | ||
@@ -1548,6 +1633,8 @@ | ||
it("should call both middlewares", () => { | ||
it("should call global & route middlewares", () => { | ||
const broker = new ServiceBroker(); | ||
broker.loadService("./test/services/test.service"); | ||
const mwg = jest.fn((req, res, next) => next()); | ||
const mw1 = jest.fn((req, res, next) => { | ||
@@ -1564,2 +1651,3 @@ res.setHeader("X-Custom-Header", "middleware"); | ||
settings: { | ||
use: [mwg], | ||
routes: [{ | ||
@@ -1580,2 +1668,5 @@ path: "/", | ||
expect(res.body).toBe("Hello Moleculer"); | ||
expect(mwg).toHaveBeenCalledTimes(1); | ||
expect(mwg).toHaveBeenCalledWith(jasmine.any(http.IncomingMessage), jasmine.any(http.ServerResponse), jasmine.any(Function)); | ||
expect(mw1).toHaveBeenCalledTimes(1); | ||
@@ -1582,0 +1673,0 @@ expect(mw1).toHaveBeenCalledWith(jasmine.any(http.IncomingMessage), jasmine.any(http.ServerResponse), jasmine.any(Function)); |
@@ -36,2 +36,12 @@ "use strict"; | ||
reqres: { | ||
handler(ctx) { | ||
return { | ||
hasReq: !!ctx.params.$req, | ||
hasRes: !!ctx.params.$res, | ||
a: ctx.params.a | ||
}; | ||
} | ||
}, | ||
dangerZone: { | ||
@@ -38,0 +48,0 @@ publish: false, |
1057062
0.77%8
-11.11%62
1.64%4431
5.12%