Skip to content

Commit

Permalink
[FEATURE] Server: Add handling for custom middleware (#200)
Browse files Browse the repository at this point in the history
As per RFC 0005: SAP/ui5-tooling#151
  • Loading branch information
RandomByte authored Jul 10, 2019
1 parent 14571e2 commit 037b3bc
Show file tree
Hide file tree
Showing 15 changed files with 870 additions and 126 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
## Server
Provides server capabilities for the [UI5 Tooling](https://github.com/SAP/ui5-tooling).

### Middlewares
The development server has already a set of middlewares which supports the developer with the following features:
### Middleware
The development server has already a set of middleware which supports the developer with the following features:

* Translation files with `.properties` extension are properly encoded with **ISO-8859-1**.
* Changes on files with `.less` extension triggers a theme build and delivers the compiled CSS files.
Expand Down
32 changes: 26 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,33 @@
module.exports = {
server: require("./lib/server"),
sslUtil: require("./lib/sslUtil"),
middlewareRepository: require("./lib/middleware/middlewareRepository"),

// Legacy middleware export. Still private.
middleware: {
csp: require("./lib/middleware/csp"),
discovery: require("./lib/middleware/discovery"),
nonReadRequests: require("./lib/middleware/discovery"),
serveIndex: require("./lib/middleware/serveIndex"),
serveResources: require("./lib/middleware/serveResources"),
serveThemes: require("./lib/middleware/serveThemes"),
versionInfo: require("./lib/middleware/versionInfo"),
discovery: mapLegacyMiddlewareArguments(require("./lib/middleware/discovery")),
nonReadRequests: mapLegacyMiddlewareArguments(require("./lib/middleware/discovery")),
serveIndex: mapLegacyMiddlewareArguments(require("./lib/middleware/serveIndex")),
serveResources: mapLegacyMiddlewareArguments(require("./lib/middleware/serveResources")),
serveThemes: mapLegacyMiddlewareArguments(require("./lib/middleware/serveThemes")),
versionInfo: mapLegacyMiddlewareArguments(require("./lib/middleware/versionInfo")),
}
};

function mapLegacyMiddlewareArguments(module) {
// Old arguments was a single object with optional properties
// - resourceCollections
// - tree
return function({resourceCollections, tree} = {}) {
const resources = {};
resources.all = resourceCollections.combo;
resources.rootProject = resourceCollections.source;
resources.dependencies = resourceCollections.dependencies;

return module({
resources,
tree
});
};
}
178 changes: 178 additions & 0 deletions lib/middleware/MiddlewareManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
const middlewareRepository = require("./middlewareRepository");
/**
*
*
* @memberof module:@ui5/server.middleware
*/
class MiddlewareManager {
constructor({tree, resources, options = {
sendSAPTargetCSP: false
}}) {
if (!tree || !resources || !resources.all || !resources.rootProject || !resources.dependencies) {
throw new Error("[MiddlewareManager]: One or more mandatory parameters not provided");
}
this.tree = tree;
this.resources = resources;
this.options = options;

this.middleware = {};
this.middlewareExecutionOrder = [];
}

async applyMiddleware(app) {
await this.addStandardMiddleware();
await this.addCustomMiddleware();

return this.middlewareExecutionOrder.map((name) => {
const m = this.middleware[name];
app.use(m.mountPath, m.middleware);
});
}

async addMiddleware(middlewareName, {
wrapperCallback, mountPath = "/",
beforeMiddleware, afterMiddleware
} = {}) {
let middlewareCallback = middlewareRepository.getMiddleware(middlewareName);
if (wrapperCallback) {
middlewareCallback = wrapperCallback(middlewareCallback);
}
if (this.middleware[middlewareName] || this.middlewareExecutionOrder.includes(middlewareName)) {
throw new Error(`Failed to add duplicate middleware ${middlewareName}`);
}

if (beforeMiddleware || afterMiddleware) {
const refMiddlewareName = beforeMiddleware || afterMiddleware;
let refMiddlewareIdx = this.middlewareExecutionOrder.indexOf(refMiddlewareName);
if (refMiddlewareIdx === -1) {
throw new Error(`Could not find middleware ${refMiddlewareName}, referenced by custom ` +
`middleware ${middlewareName}`);
}
if (afterMiddleware) {
// Insert after index of referenced middleware
refMiddlewareIdx++;
}
this.middlewareExecutionOrder.splice(refMiddlewareIdx, 0, middlewareName);
} else {
this.middlewareExecutionOrder.push(middlewareName);
}

this.middleware[middlewareName] = {
middleware: await Promise.resolve(middlewareCallback({resources: this.resources})),
mountPath
};
}

async addStandardMiddleware() {
await this.addMiddleware("csp", {
wrapperCallback: (cspModule) => {
const oCspConfig = {
allowDynamicPolicySelection: true,
allowDynamicPolicyDefinition: true,
definedPolicies: {
"sap-target-level-1":
"default-src 'self'; " +
"script-src 'self' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self' data:; " +
"img-src 'self' * data: blob:; " +
"frame-src 'self' https: data: blob:; " +
"child-src 'self' https: data: blob:; " +
"connect-src 'self' https: wss:;",
"sap-target-level-2":
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self' data:; " +
"img-src 'self' * data: blob:; " +
"frame-src 'self' https: data: blob:; " +
"child-src 'self' https: data: blob:; " +
"connect-src 'self' https: wss:;"
}
};
if (this.options.sendSAPTargetCSP) {
Object.assign(oCspConfig, {
defaultPolicy: "sap-target-level-1",
defaultPolicyIsReportOnly: true,
defaultPolicy2: "sap-target-level-2",
defaultPolicy2IsReportOnly: true,
});
}
return () => {
return cspModule("sap-ui-xx-csp-policy", oCspConfig);
};
}
});
await this.addMiddleware("compression");
await this.addMiddleware("cors");
await this.addMiddleware("discovery", {
mountPath: "/discovery"
});
await this.addMiddleware("serveResources");
await this.addMiddleware("serveThemes");
await this.addMiddleware("versionInfo", {
mountPath: "/resources/sap-ui-version.json",
wrapperCallback: (versionInfoModule) => {
return ({resources}) => {
return versionInfoModule({
resources,
tree: this.tree
});
};
}
});
await this.addMiddleware("connectUi5Proxy", {
mountPath: "/proxy"
});
// Handle anything but read operations *before* the serveIndex middleware
// as it will reject them with a 405 (Method not allowed) instead of 404 like our old tooling
await this.addMiddleware("nonReadRequests");
await this.addMiddleware("serveIndex");
}

async addCustomMiddleware() {
const project = this.tree;
const projectCustomMiddleware = project.server && project.server.customMiddleware;
if (!projectCustomMiddleware || projectCustomMiddleware.length === 0) {
return; // No custom middleware defined
}

for (let i = 0; i < projectCustomMiddleware.length; i++) {
const middlewareDef = projectCustomMiddleware[i];
if (!middlewareDef.name) {
throw new Error(`Missing name for custom middleware definition of project ${project.metadata.name} ` +
`at index ${i}`);
}
if (middlewareDef.beforeMiddleware && middlewareDef.afterMiddleware) {
throw new Error(
`Custom middleware definition ${middlewareDef.name} of project ${project.metadata.name} ` +
`defines both "beforeMiddleware" and "afterMiddleware" parameters. Only one must be defined.`);
}
if (!middlewareDef.beforeMiddleware && !middlewareDef.afterMiddleware) {
throw new Error(
`Custom middleware definition ${middlewareDef.name} of project ${project.metadata.name} ` +
`defines neither a "beforeMiddleware" nor an "afterMiddleware" parameter. One must be defined.`);
}

if (this.middleware[middlewareDef.name]) {
// Middleware is already known
throw new Error(`Failed to add custom middleware ${middlewareDef.name}. ` +
`A middleware with the same name is already known.`);
}
await this.addMiddleware(middlewareDef.name, {
wrapperCallback: (middleware) => {
return ({resources}) => {
const options = {
configuration: middlewareDef.configuration
};
return middleware({resources, options});
};
},
mountPath: middlewareDef.mountPath,
beforeMiddleware: middlewareDef.beforeMiddleware,
afterMiddleware: middlewareDef.afterMiddleware
});
}
}
}
module.exports = MiddlewareManager;
9 changes: 9 additions & 0 deletions lib/middleware/connectUi5Proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const ui5connect = require("connect-openui5");

function createMiddleware() {
return ui5connect.proxy({
secure: false
});
}

module.exports = createMiddleware;
18 changes: 10 additions & 8 deletions lib/middleware/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ const urlPattern = /\/(app_pages|all_libs|all_tests)(?:[?#].*)?$/;
* </ul>
*
* @module @ui5/server/middleware/discovery
* @param {Object} resourceCollections Contains the resource reader or collection to access project related files
* @param {module:@ui5/fs.AbstractReader} resourceCollections.source Resource reader or collection for the source project
* @param {module:@ui5/fs.AbstractReader} resourceCollections.combo Resource collection which contains the workspace and the project dependencies
* @param {Object} parameters Parameters
* @param {module:@ui5/fs.AbstractReader} parameters.resources.all Reader or Collection to read resources of the
* root project and its dependencies
* @param {module:@ui5/fs.AbstractReader} parameters.resources.rootProject Reader or Collection to read resources of
* the project the server is started in
* @returns {Function} Returns a server middleware closure.
*/
function createMiddleware({resourceCollections}) {
function createMiddleware({resources}) {
return function discoveryMiddleware(req, res, next) {
const parts = urlPattern.exec(req.url);
const type = parts && parts[1];
Expand Down Expand Up @@ -46,7 +48,7 @@ function createMiddleware({resourceCollections}) {
}

if (type === "app_pages") {
resourceCollections.source.byGlob("/**/*.{html,htm}").then(function(resources) {
resources.rootProject.byGlob("/**/*.{html,htm}").then(function(resources) {
resources.forEach(function(resource) {
const relPath = resource.getPath().substr(1); // cut off leading "/"
response.push({
Expand All @@ -56,7 +58,7 @@ function createMiddleware({resourceCollections}) {
sendResponse();
});
} else if (type === "all_libs") {
resourceCollections.combo.byGlob([
resources.all.byGlob([
"/resources/**/*.library"
]).then(function(resources) {
resources.forEach(function(resource) {
Expand All @@ -72,8 +74,8 @@ function createMiddleware({resourceCollections}) {
});
} else if (type === "all_tests") {
Promise.all([
resourceCollections.combo.byGlob("/resources/**/*.library"),
resourceCollections.combo.byGlob("/test-resources/**/*.{html,htm}")
resources.all.byGlob("/resources/**/*.library"),
resources.all.byGlob("/test-resources/**/*.{html,htm}")
]).then(function(results) {
const libraryResources = results[0];
const testPageResources = results[1];
Expand Down
33 changes: 33 additions & 0 deletions lib/middleware/middlewareRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const middlewares = {
compression: "compression",
cors: "cors",
csp: "./csp",
serveResources: "./serveResources",
serveIndex: "./serveIndex",
discovery: "./discovery",
versionInfo: "./versionInfo",
connectUi5Proxy: "./connectUi5Proxy",
serveThemes: "./serveThemes",
nonReadRequests: "./nonReadRequests"
};

function getMiddleware(middlewareName) {
const middlewarePath = middlewares[middlewareName];

if (!middlewarePath) {
throw new Error(`middlewareRepository: Unknown Middleware ${middlewareName}`);
}
return require(middlewarePath);
}

function addMiddleware(name, middlewarePath) {
if (middlewares[name]) {
throw new Error(`middlewareRepository: Middleware ${name} already registered`);
}
middlewares[name] = middlewarePath;
}

module.exports = {
getMiddleware: getMiddleware,
addMiddleware: addMiddleware
};
8 changes: 4 additions & 4 deletions lib/middleware/serveIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,16 @@ function createContent(path, resourceInfos) {
* Creates and returns the middleware to serve a resource index.
*
* @module @ui5/server/middleware/serveIndex
* @param {Object} resourceCollections Contains the resource reader or collection to access project related files
* @param {module:@ui5/fs.AbstractReader} resourceCollections.combo Resource collection which contains the workspace and the project dependencies
* @param {Object} resources Contains the resource reader or collection to access project related files
* @param {module:@ui5/fs.AbstractReader} resources.all Resource collection which contains the workspace and the project dependencies
* @returns {Function} Returns a server middleware closure.
*/
function createMiddleware({resourceCollections}) {
function createMiddleware({resources}) {
return function serveIndex(req, res, next) {
const pathname = parseurl(req).pathname;
log.verbose("\n Listing index of " + pathname);
const glob = pathname + (pathname.endsWith("/") ? "*" : "/*");
resourceCollections.combo.byGlob(glob, {nodir: false}).then((resources) => {
resources.all.byGlob(glob, {nodir: false}).then((resources) => {
if (!resources || resources.length == 0) { // Not found
next();
return;
Expand Down
8 changes: 4 additions & 4 deletions lib/middleware/serveResources.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ function isFresh(req, res) {
* Creates and returns the middleware to serve application resources.
*
* @module @ui5/server/middleware/serveResources
* @param {Object} resourceCollections Contains the resource reader or collection to access project related files
* @param {module:@ui5/fs.AbstractReader} resourceCollections.combo Resource collection which contains the workspace and the project dependencies
* @param {Object} resources Contains the resource reader or collection to access project related files
* @param {module:@ui5/fs.AbstractReader} resources.all Resource collection which contains the workspace and the project dependencies
* @returns {Function} Returns a server middleware closure.
*/
function createMiddleware({resourceCollections}) {
function createMiddleware({resources}) {
return function serveResources(req, res, next) {
const pathname = parseurl(req).pathname;
resourceCollections.combo.byPath(pathname).then(function(resource) {
resources.all.byPath(pathname).then(function(resource) {
if (!resource) { // Not found
next();
return;
Expand Down
10 changes: 5 additions & 5 deletions lib/middleware/serveThemes.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ const themeRequest = /^(.*\/)library(?:(\.css)|(-RTL\.css)|(-parameters\.json))$
* The theme is built in realtime. If a less file was modified, the theme build is triggered to rebuild the theme.
*
* @module @ui5/server/middleware/serveThemes
* @param {Object} resourceCollections Contains the resource reader or collection to access project related files
* @param {module:@ui5/fs.AbstractReader} resourceCollections.combo Resource collection which contains the workspace and the project dependencies
* @param {Object} resources Contains the resource reader or collection to access project related files
* @param {module:@ui5/fs.AbstractReader} resources.all Resource collection which contains the workspace and the project dependencies
* @returns {Function} Returns a server middleware closure.
*/
function createMiddleware({resourceCollections}) {
function createMiddleware({resources}) {
const builder = new themeBuilder.ThemeBuilder({
fs: fsInterface(resourceCollections.combo)
fs: fsInterface(resources.all)
});

return function theme(req, res, next) {
Expand All @@ -46,7 +46,7 @@ function createMiddleware({resourceCollections}) {
}

const sourceLessPath = themeReq[1] + "library.source.less";
resourceCollections.combo.byPath(sourceLessPath).then((sourceLessResource) => {
resources.all.byPath(sourceLessPath).then((sourceLessResource) => {
if (!sourceLessResource) { // Not found
next();
return;
Expand Down
Loading

0 comments on commit 037b3bc

Please sign in to comment.