An Express.js Learning Experiment - Porting FW/1 Ideas Into A Node.js Application
I've spent about a week and half diving into Express.js - one of the more popular Node.js web application frameworks. Of that time, about a week has been spent just trying to understand the concept of Middleware, which has many subtleties like arbitrary nesting. To continue picking apart middleware functionality, I thought I would try to build a FW/1 (Framework One) inspired flow-of-control middleware on top of the Express.js routing system.
View this experiment on GitHub.
FW/1 (Framework One) is a lightweight, convention-over-configuration ColdFusion framework developed by the esteemed Sean Corfield. While a relatively simple framework, it provides - in conjunction with other small libraries - complex functionality like Model-View-Controller (MVC), Dependency Injection (DI), Inversion of Control (IoC), and Aspect Oriented Programming (AoP). Much of this functionality goes far beyond the scope of what I'm currently learning. So, what I really want to do is take the good parts of the FW/1 routing and request management and try to bake that in to some Express.js middleware layer.
The routing-relevant parts of FW/1 that I think would be interesting in porting over to Express.js are:
- Comprehensive overview of routes - I want all the routes to be defined in one place, in the root of the application. This way, any developer can, at a glance, see exactly what routes the application is supporting. No digging through various folders trying to mentally piece together where a request might be directed.
- Controllers - When I look at an Express.js application, one of the biggest points of friction is that it doesn't have controllers. It just has "route handlers". This makes Inversion of Control (IoC) and providing shared functionality a bit more difficult (though not impossible). And, I think it makes people lazy about how they instantiate and configure classes in a Node.js application.
- Auto-Rendering of views - Rather than having to explicitly render a view from within a router handler / Controller, I'd like FW/1 to automatically pair a view with a Controller. Not only does this reduce boilerplate, it also allows for view rendering without a Controller method. And, it decouples the business logic from the view logic.
- Life-Cycle methods - With Controllers, we can start to implement methods that get called before and after every controller method. This provides a clean way to implement shared functionality like security and variable initialization.
- Unified request collection - Rather than picking pieces of data from various inputs, all query-string params, body params, route params, and "locals" are jammed into a single "rc" or "Request Collection".
A FW/1 application is (or can be) organized into subsystems. These are macro areas of functionality that have their own set of controllers, views, layouts, and life-cycle concerns. A typical subsystem folder structure might look something like this:
/subsystems
|-- /api
|-- |-- /controllers
|-- |-- |-- some-controller.js
|-- |-- /layouts
|-- |-- |-- some-controller.pug
|-- |-- /views
|-- |-- |-- /some-controller
|-- |-- |-- |-- method-a.pug
|-- |-- |-- |-- method-b.pug
|-- /common
|-- /desktop
|-- /mobile
Identifying a controller for execution is done through a controller notation syntax that follows this pattern:
subsystem:controller.method
So, if I wanted to invoke the getMovies() method on the Movies controller in the API subsystem, the proper controller notation would look like:
api:movies.getMovies
Now, in ColdFusion, there are built-in application life-cycle methods that we can use to help configure the flow-of-control in a FW/1 request. But, ColdFusion is an inherently blocking language and doesn't need to use of constructs like next() that Express.js provides. As such, I went about trying to build life-cycle method control in a slightly different way. In addition to the Controller-based life-cycle methods - which are naturally part of the Controller interface - I created separate "life-cycle controllers" that can be provided at the root level and the subsystem level:
/subsystems
|-- lifecycle.js
|-- /api
|-- |-- lifecycle.js
|-- |-- /controllers
|-- |-- |-- some-controller.js
|-- |-- /layouts
|-- |-- |-- some-controller.pug
|-- |-- /views
|-- |-- |-- /some-controller
|-- |-- |-- |-- method-a.pug
|-- |-- |-- |-- method-b.pug
|-- /common
|-- |-- lifecycle.js
|-- /desktop
|-- |-- lifecycle.js
|-- /mobile
|-- |-- lifecycle.js
Each normal Controller and Life-Cycle Controller can provide the following methods:
- onBefore - Gets called before controller invocation.
- onAfter - Gets called after controller invocation but before view rendering.
- onError - Handles local errors.
So, for example, if you provided an onBefore() method in a subsystem-level life-cycle controller, it would be invoked before every controller method in that entire subsystem.
Now, the ColdFusion version of FW/1 will scour the file-system and, using DI/1, will instantiate and auto-wire the controllers. This kind of functionality is far beyond the scope of this exercise; so, when I'm bootstrapping the application, I manually instantiate the various Services and Controllers and provide those instances to FW/1. This way, FW/1 only has to worry about route management, controller invocation, and view rendering.
To get a sense of how this all fits together, let's look at App.js for my demo application.
// Require the core node modules.
var bodyParser = require( "body-parser" );
var cookieParser = require( "cookie-parser" );
var express = require( "express" );
var logger = require( "morgan" );
var path = require( "path" );
// Require the application node modules.
var fw1 = require( "./fw1" );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// Require the Services.
var MovieService = require( "./lib/movie-service" ).MovieService;
var UserService = require( "./lib/user-service" ).UserService;
// Require the life-cycle controllers.
var SubsystemsLifeCycle = require( "./subsystems/lifecycle" ).SubsystemsLifeCycle;
var ApiLifeCycle = require( "./subsystems/api/lifecycle" ).ApiLifeCycle;
var DesktopLifeCycle = require( "./subsystems/desktop/lifecycle" ).DesktopLifeCycle;
// Require the normal controllers.
var controllers = {
desktop: {
MainController: require( "./subsystems/desktop/controllers/main" ).MainController,
SecurityController: require( "./subsystems/desktop/controllers/security" ).SecurityController
},
api: {
MoviesController: require( "./subsystems/api/controllers/movies" ).MoviesController
}
};
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
var movieService = new MovieService();
var userService = new UserService();
var app = module.exports = express();
// Setup the view engine.
// --
// CAUTION: The fw1() middleware expects view root to be the working directory.
app.set( "views" , __dirname );
app.set( "view engine" , "pug" );
// Setup global middleware.
// --
// TODO: This could be moved inside fw1() somehow to include common default middlewares.
// But, then I would need to give fw1 access to the app somehow.
app.use( logger( "dev" ) );
app.use( cookieParser() );
app.use( bodyParser.urlencoded({ extended: false }) );
app.use( bodyParser.json() );
app.use( express.static( path.join( __dirname, "public" ) ) );
// Setup fw1 controllers and routes.
app.use(
fw1(
// Define the subsystems and controllers. These are actual instances of the
// various controllers that fw1 will invoke based on the incoming route.
{
desktop: {
main: new controllers.desktop.MainController(),
security: new controllers.desktop.SecurityController( userService )
},
api: {
movies: new controllers.api.MoviesController( movieService )
}
},
// Define the global life-cycle controllers. These handle request-level and
// subsystem-level shared concerns like authentication and error handling.
// --
// NOTE: The "subsystems" is the root-level handler - this is a special name. The
// rest of the names have to match the names of individual subsystems.
{
subsystems: new SubsystemsLifeCycle( userService ),
api: new ApiLifeCycle(),
desktop: new DesktopLifeCycle()
},
// Define the route mappings.
// --
// NOTE: The order here is not important since any pre/post route middleware
// should be handled by the life-cycle methods.
{
// Desktop routes.
"GET /": "desktop:main.default",
"GET /login": "desktop:security.login",
"POST /login": "desktop:security.processLogin",
"GET /sign-up": "desktop:security.signup",
"POST /sign-up": "desktop:security.processSignup",
"GET /logout": "desktop:security.processLogout",
// API routes.
"GET /api/movies": "api:movies.getMovies",
"POST /api/movies": "api:movies.createMovie",
"DELETE /api/movies/:movieId": "api:movies.deleteMovie"
}
)
);
As you can see, fw1() is just, itself, a piece of middleware that is being plugged into Express.js. And, when I create an instance of the fw1() middleware, I am providing:
- Controllers - Structure to reflect the subsystems folder structure.
- Life-Cycle Controllers - Named to reflect the subsystems folder structure.
- Routes - Mapping URLs onto Controller notations.
Notice that each route definition is simply a URL pattern mapped to a Controller Notation. The fw1() middleware will take care of actually invoking the Controller. And, the reason this is important is because fw1() is actually providing a host of middleware methods that wrap around the invocation. Under the hood (which you can see down below), each route is actually passed through the following fw1() middleware:
- initializeRequest()
- beforeRequestMiddleware()
- beforeSubsystemMiddleware()
- beforeControllerMiddleware()
- executeControllerMiddleware() <-- Your actual controller.
- afterControllerMiddleware()
- afterSubsystemMiddleware()
- afterRequestMiddleware()
- notFoundMiddleware()
- controllerErrorMiddleware()
- subsystemErrorMiddleware()
- requestErrorMiddleware()
- renderMiddleware()
- fatalErrorMiddleware()
Before we look at how the fw1() middleware is actually working, let's look at a few code examples. In my demo, I am providing a root-level life-cycle controller method that looks at the user's cookies and configures a "request.rc.user" that can be used by every other Controller in the same request:
// Require the core node modules.
var chalk = require( "chalk" );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
class SubsystemsLifeCycle {
// I initialize the life-cycle controller.
constructor( userService ) {
this._userService = userService;
}
// ---
// LIFE-CYCLE METHODS.
// ---
// I get called before every single request to the application.
onBefore( request, response, next ) {
// Setup the default user object for the request. This will determine the current
// user's authentication status in the application.
request.rc.user = {
id: 0,
username: "",
isAuthenticated: false
};
// If there's no session cookie, there's no user to validate yet. Move onto the
// next controller.
if ( ! request.cookies.sessionId ) {
return( next() );
}
// If we made it this far, the use has a session cookie; but, we need to validate
// that it's actually valid for a user.
// --
// CAUTION: For simplicity, this session is blindly using the given user ID as
// the source of truth. In reality, you would need MUCH MORE SECURE sessions.
this._userService
.getUser( +request.cookies.sessionId )
.then(
( user ) => {
request.rc.user = {
id: user.id,
username: user.username,
isAuthenticated: true
};
},
( error ) => {
// Ignore any not-found error, let the default user fall-through.
// But, expire the session since the cookie clearly is not valid.
response.cookie( "sessionId", "", { expires: new Date( 0 ) } );
}
)
.then( next )
.catch( next )
;
}
// I get called for any error that bubbles up from one of the subsystems.
// --
// CAUTION: This is only for errors that bubble up in the synchronous portions of the
// controllers, or those which are explicitly propagated using next(). This will not
// catch errors that are thrown during the generally-asynchronous flow of control.
onError( error, request, response ) {
console.log( chalk.red.bold( "Global Error Handler:" ) );
console.log( error );
// If the headers have already been sent, it's too late to adjust the output, so
// just end the response so it doesn't hang.
if ( response.headersSent ) {
return( response.end() );
}
// Render the not found error page.
if ( error.message === "Not Found" ) {
response.status( 404 );
response.setView( "common:error.notfound" );
} else {
// Render the fatal error page.
response.rc.title = "Server Error";
response.rc.description = "An unexpected error occurred. Our team is looking into it.";
response.status( 500 );
response.setView( "common:error.fatal" );
}
}
}
exports.SubsystemsLifeCycle = SubsystemsLifeCycle;
NOTE: Notice that I am using response.setView() to change the template associated with the request. This is provided by the fw1() middleware.
For the sake of the demo, I'm using super naive, in-memory session management. You basically provide a "sessionId" - which is a user ID - and the system will log you in as that user. Obviously this is highly insecure; but, it demonstrates the point. If the cookie is available, the root-level life-cycle controller will look the user up in the injected UserService and provide the authenticated user structure for the rest of the request.
At the subsystem level, we can then use the life-cycle controller to manage security based on the value provided previously. For example, here's the life-cycle controller in my API subsystem, which requires all calls to the API to be authenticated:
// Require the core node modules.
var chalk = require( "chalk" );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
class ApiLifeCycle {
// I initialize the life-cycle controller.
constructor() {
// ...
}
// ---
// LIFE-CYCLE METHODS.
// ---
// I get called before every single request to this subsystem.
onBefore( request, response ) {
// All requests to this subsystem must be made by authenticated users. If the
// user is not authenticating, reject the request.
if ( ! request.rc.user.isAuthenticated ) {
throw( new Error( "Unauthorized" ) );
}
// All controllers are intended to overwrite this property, which will be
// returned back to the client.
response.rc.data = true;
}
// I get called after every single request to this subsystem.
onAfter( request, response ) {
// Return the API response to the client.
response.json({
ok: true,
data: response.rc.data
});
}
// I get called for any error that bubbles up in the API subsystem.
// --
// CAUTION: This is only for errors that bubble up in the synchronous portions of the
// controllers, or those which are explicitly propagated using next(). This will not
// catch errors that are thrown during the generally-asynchronous flow of control.
onError( error, request, response ) {
console.log( chalk.red.bold( "API Error Handler:" ) );
console.log( error );
// If the headers have already been sent, it's too late to adjust the output, so
// just end the response so it doesn't hang.
if ( response.headersSent ) {
return( response.end() );
}
// Since all API calls are intending to return JSON, let's render the error
// as such.
// --
// NOTE: For the demo, I am keeping this error handling fairly naive.
response
.status( this._getStatusCode( error ) )
.json({
ok: false,
data: "Something went wrong."
})
;
}
// ---
// PRIVATE METHODS.
// ---
// I try to reduce the given error into a meaningful HTTP status code.
_getStatusCode( error ) {
switch ( error.message.toLowerCase() ) {
case "unauthorized":
return( 401 );
break;
case "not found":
return( 404 );
break;
case "invalid argument":
return( 400 );
break;
default:
return( 500 );
break;
}
}
}
exports.ApiLifeCycle = ApiLifeCycle;
As you can see, the onBefore() enforces access by authenticated users only. But, this life-cycle controller also handles the rendering of the API responses, either in success or in error, doing its best to provide a valid JSON (JavaScript Object Notation) response when possible.
Once the root-level life-cycle controller and the API subsystem-level life-controller have executed, the actual route controller will execute. Since we're already looking in the API subsystem, let's look at the Movies API controller:
class MoviesController {
// I initialize the controller.
constructor( movieService ) {
this._movieService = movieService;
}
// ---
// HANDLER METHODS.
// ---
// I create a movie with the provided name.
createMovie( request, response, next ) {
this._movieService
.createMovie( request.rc.user.id, request.rc.name )
.then(
( movieId ) => {
response.rc.data = movieId;
}
)
.then( next )
.catch( next )
;
}
// I delete a movie with the provided id.
deleteMovie( request, response, next ) {
this._movieService
.deleteMovie( request.rc.user.id, +request.rc.movieId )
.then(
() => {
response.rc.data = true;
}
)
.then( next )
.catch( next )
;
}
// I get the movies for the context users.
getMovies( request, response, next ) {
this._movieService
.getMovies( request.rc.user.id )
.then(
( movies ) => {
response.rc.data = movies;
}
)
.then( next )
.catch( next )
;
}
}
exports.MoviesController = MoviesController;
There's nothing too special going on here. Each method is simply passing the request onto a Service object and then stuffing the result back in the rc (Request Collection). The rc object is then used to render the view, either explicitly as we are doing in the onAfter() life-cycle controller method; or, implicitly by fw1() if we didn't end the request.
Ok, now that you have a sense of the Controller-layer of request routing, let's look at the fw1() middleware that actually turns a request into a series of controller-oriented middleware:
NOTE: The code in this middleware is written to be read top-down; so, the middleware functions are defined, essentially, in the order in which they are applied to the request.
// Require the core node modules.
var chalk = require( "chalk" );
var express = require( "express" );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
module.exports = function fw1( controllers, lifecycleControllers, routeMappings ) {
var router = express.Router();
// Mount each route to run through a series of FW1 route-related middleware.
Object.entries( routeMappings ).forEach(
( [ routeRequest, routeController ] ) => {
setupMiddlewareForRouteMapping( routeRequest, routeController );
}
);
// After the routes are configured, we need to add some router-global middleware
// that need to run regardless of whether or not a specific route was matched.
router.use([
notFoundMiddleware,
controllerErrorMiddleware,
subsystemErrorMiddleware,
requestErrorMiddleware,
renderMiddleware,
fatalErrorMiddleware
]);
return( router );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// I mount the route and setup the fw1 namespace.
function setupMiddlewareForRouteMapping( routeRequest, routeController ) {
var routeParts = routeRequest.split( " " );
// Determine which HTTP METHOD to mount with.
if ( routeParts.length === 1 ) {
var routeMethod = "all";
var routePath = routeRequest;
} else {
var routeMethod = routeParts[ 0 ].toLowerCase();
var routePath = routeParts[ 1 ];
}
var controllerParts = parseControllerNotation( routeController );
// CAUTION: All, some, or none of these properties will actually exist on the
// controllers collection since there is no guarantee that we're actually using
// controllers to process the request. If a request only has view assets, then
// there will be no associated controller (but, we'll parameterize what we can
// below so as to make the request processing easier).
var subsystemName = controllerParts.subsystemName;
var controllerName = controllerParts.controllerName;
var methodName = controllerParts.methodName;
// Let's param the various controllers so that we don't actually have to check to
// see if they exist when processing the middleware.
// Param request-level life-cycle controller.
// --
// NOTE: Since this one isn't really tied to a route, it will be called many
// times unnecessarily; but, I'm keeping it here, regardless, so that all of the
// controller parameterization is in the same place. This is only done on app
// start-up, so the cost isn't a significant consideration.
if ( ! lifecycleControllers.subsystems ) {
lifecycleControllers.subsystems = Object.create( null );
}
// Param subsystem-level life-cycle controller.
if ( ! lifecycleControllers[ subsystemName ] ) {
lifecycleControllers[ subsystemName ] = Object.create( null );
}
// Param subsystem collection.
if ( ! controllers[ subsystemName ] ) {
controllers[ subsystemName ] = Object.create( null );
}
// Param subsystem controller.
if ( ! controllers[ subsystemName ][ controllerName ] ) {
controllers[ subsystemName ][ controllerName ] = Object.create( null );
}
// Mount the route middleware.
router[ routeMethod ](
routePath,
function initializeRequest( request, response, next ) {
request.fw1 = {
subsystemName: subsystemName,
controllerName: controllerName,
methodName: methodName,
// At this time, these are just here for debugging.
route: {
method: routeMethod,
path: routePath,
controller: routeController
}
};
response.fw1 = {
// NOTE: The request and response values are stored separately
// because the response values can be overridden to render views
// that are not inherently tied to the request values.
subsystemName: subsystemName,
controllerName: controllerName,
methodName: methodName
};
// By default, the view is calculated based on the request route mapping;
// however, a controller can explicitly set the view to be used when
// rendering the output. This view uses the controller notation which
// means it can be relative to a context:
// --
// Relative to Controller: ".method"
// Relative to Subsystem: "controller.method"
// Relative to Root: "subsystem:controller.method"
response.setView = function( controllerNotation ) {
var config = parseControllerNotation( controllerNotation );
// In order to make the notation relative, pull from the request any
// portions that are undefined in the parsed value.
response.fw1.subsystemName = ( config.subsystemName || request.fw1.subsystemName );
response.fw1.controllerName = ( config.controllerName || request.fw1.controllerName );
response.fw1.methodName = ( config.methodName || request.fw1.methodName );
};
// Setup the "request collection" that unifies the access to the
// various request inputs and response outputs. Since this is built
// on top of the response.locals collection, it will be available
// within the view rendering.
applyRequestCollection( request, response );
next();
},
// The rest of the middleware (on this route) here can now assume that the
// "fw1" object exists on both the request and the response.
beforeRequestMiddleware,
beforeSubsystemMiddleware,
beforeControllerMiddleware,
executeControllerMiddleware,
afterControllerMiddleware,
afterSubsystemMiddleware,
afterRequestMiddleware
);
}
// I run before every fw1 request.
function beforeRequestMiddleware( request, response, next ) {
safelyInvokeController( lifecycleControllers[ "subsystems" ], "onBefore", request, response, next );
}
// I run before every fw1 request in the current subsystem.
function beforeSubsystemMiddleware( request, response, next ) {
var subsystemName = request.fw1.subsystemName;
safelyInvokeController( lifecycleControllers[ subsystemName ], "onBefore", request, response, next );
}
// I run before every fw1 request in the current controller.
function beforeControllerMiddleware( request, response, next ) {
var subsystemName = request.fw1.subsystemName;
var controllerName = request.fw1.controllerName;
safelyInvokeController( controllers[ subsystemName ][ controllerName ], "onBefore", request, response, next );
}
// I execute the actual controller method in the request.
function executeControllerMiddleware( request, response, next ) {
var subsystemName = request.fw1.subsystemName;
var controllerName = request.fw1.controllerName;
var methodName = request.fw1.methodName;
safelyInvokeController( controllers[ subsystemName ][ controllerName ], methodName, request, response, next );
}
// I run after every fw1 request in the current controller.
function afterControllerMiddleware( request, response, next ) {
var subsystemName = request.fw1.subsystemName;
var controllerName = request.fw1.controllerName;
safelyInvokeController( controllers[ subsystemName ][ controllerName ], "onAfter", request, response, next );
}
// I run after every fw1 request in the current subsystem.
function afterSubsystemMiddleware( request, response, next ) {
var subsystemName = request.fw1.subsystemName;
safelyInvokeController( lifecycleControllers[ subsystemName ], "onAfter", request, response, next );
}
// I run after every fw1 request.
function afterRequestMiddleware( request, response, next ) {
safelyInvokeController( lifecycleControllers[ "subsystems" ], "onAfter", request, response, next );
}
// I check to see if the current request was caught by a fw1 route mapping; and, if
// not, throw a Not Found error.
// --
// CAUTION: This assumes that static asset service was configured to run before the
// fw1 request middleware.
function notFoundMiddleware( request, response, next ) {
if ( isFw1Request( request ) ) {
return( next() );
}
// If we made it this far in the request and the request was not associated with
// a mapped route that initialized the fw1 object, then this request will not be
// handled by one of the controllers.
var error = new Error( "Not Found" );
error.status = 404;
throw( error );
}
// I attempt to pipe error-handling into the current controller (if available).
function controllerErrorMiddleware( error, request, response, next ) {
if ( isFw1Request( request ) ) {
var subsystemName = request.fw1.subsystemName;
var controllerName = request.fw1.controllerName;
safelyInvokeController( controllers[ subsystemName ][ controllerName ], "onError", error, request, response, next );
} else {
next( error );
}
}
// I attempt to pipe error-handling into the current subsystem life-cycle controller.
function subsystemErrorMiddleware( error, request, response, next ) {
if ( isFw1Request( request ) ) {
var subsystemName = request.fw1.subsystemName;
safelyInvokeController( lifecycleControllers[ subsystemName ], "onError", error, request, response, next );
} else {
next( error );
}
}
// I attempt to pipe error-handling into the root life-cycle controller.
function requestErrorMiddleware( error, request, response, next ) {
// If this isn't a fw1 request by the time we hit the root-level error handler,
// then we need to inject enough of the fw1 functionality so that the root-level
// error handler can define the view that should be used to render the not-found
// error message.
if ( ! isFw1Request( request ) ) {
response.fw1 = {
subsystemName: "",
controllerName: "",
methodName: ""
};
// The root level error handler NEEDS TO CALL THIS METHOD in order to be able
// to render the error message in a view.
response.setView = function( controllerNotation ) {
var config = parseControllerNotation( controllerNotation );
response.fw1.subsystemName = config.subsystemName;
response.fw1.controllerName = config.controllerName;
response.fw1.methodName = config.methodName;
};
applyRequestCollection( request, response );
}
safelyInvokeController( lifecycleControllers[ "subsystems" ], "onError", error, request, response, next );
}
// I render the view after the controllers have been invoked.
function renderMiddleware( request, response, next ) {
var subsystemName = response.fw1.subsystemName;
var controllerName = response.fw1.controllerName;
var methodName = response.fw1.methodName;
if ( ! ( subsystemName && controllerName && methodName ) ) {
throw( new Error( `The response has not been sufficiently defined.` ) );
}
// CAUTION: We're not passing any "locals" into the view rendering because
// we're depending on the fact that the "response.locals" is already available,
// which is the basis for our "rc" (request collection) property.
response.render( `subsystems/${ subsystemName }/views/${ controllerName }/${ methodName }` );
}
// I handle any errors generated during view-rendering.
function fatalErrorMiddleware( error, request, response, next ) {
// If we made it this far, it means that either the application has no error
// handling; or, that the error handling itself failed; or that the view
// rendering also failed. Basically, we should never make it this far unless
// something really bad happened.
console.log( chalk.red.bold( "Fatal Error" ) );
console.log( error );
response
.status( 500 )
.send( "Unexpected Error" )
;
}
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// I inject the request collection into the request and response as a shared object
// reference (ie, changes in one will be reflected in changes in the other). The "rc"
// is based on the request.locals collection and is then augmented with the other
// request-based values.
function applyRequestCollection( request, response ) {
var rc = request.rc = response.rc = response.locals;
Object.assign( rc, request.app.locals );
Object.assign( rc, request.query );
Object.assign( rc, request.params );
Object.assign( rc, request.body );
}
// I determine if the given request has been picked-up as a FW1 request.
function isFw1Request( request ) {
return( request.hasOwnProperty( "fw1" ) );
}
// I parse the given controller notation into a has that contains the subsystemName,
// controllerName, and methodName tokens. If parts of the notation are missing, the
// resultant token will be null.
function parseControllerNotation( token ) {
var config = {
subsystemName: null,
controllerName: null,
methodName: null
};
// Trying to parse the combinations:
// --
// subsystem : controller . method
var subsystemPattern = /^(\w+):(\w+)\.(\w+)$/;
// controller . method
var controllerPattern = /^(\w+)\.(\w+)$/;
// . method
var methodPattern = /^\.?(\w+)$/;
var parts = null;
if ( parts = token.match( subsystemPattern ) ) {
config.subsystemName = parts[ 1 ];
config.controllerName = parts[ 2 ];
config.methodName = parts[ 3 ];
} else if ( parts = token.match( controllerPattern ) ) {
config.controllerName = parts[ 1 ];
config.methodName = parts[ 2 ];
} else if ( parts = token.match( methodPattern ) ) {
config.methodName = parts[ 1 ];
} else {
throw( new Error( `Unexpected controller notation [${ token }].` ) );
}
return( config );
}
// I invoke the given method on the given controller; or, safely skip over the
// invocation, passing control on to the next middleware.
function safelyInvokeController( controller, methodName, ...methodArgs ) {
var error = methodArgs[ 0 ];
var request = methodArgs[ methodArgs.length - 3 ];
var response = methodArgs[ methodArgs.length - 2 ];
var next = methodArgs[ methodArgs.length - 1 ];
var isError = ( methodArgs.length === 4 );
// If the controller doesn't have the given method, pass flow of control on to
// the next appropriate middleware (error or otherwise).
if ( ! controller[ methodName ] ) {
return( isError ? next( error ) : next() );
}
controller[ methodName ]( ...methodArgs );
// If the number of arguments defined on the method signature does not match the
// number of arguments available in the call, let's assume that we have to invoke
// the next() middleware method explicitly since the target method does not have
// a local parameter bound to the "next" argument.
// --
// WARNING: Assumes that the controller method is fully synchronous.
if ( controller[ methodName ].length !== methodArgs.length ) {
// Only call next if the response has not already been committed. If the
// headers have been sent, we have to assume that the previous Controller
// method call has finalized the response.
if ( ! response.headersSent ) {
next();
}
}
}
};
In order to not have to redefine closure-based functions for every single route, I'm defining the middlewares once and then applying them, by reference, to each route. Then, in order to reduce processing and parsing, I'm augmenting the request and response with a "fw1" namespace that can be referenced and updated by each of the middleware. This way, basically all of the parsing is done at application bootstrap and each request does little more than stick pre-calculated values into the request.
There's a good amount of code here; but, more or less, each step is little more than an Express.js middleware function that looks to see if it should invoke a method on one of the controllers, be it the route controller or one of the life-cycle controllers.
The end result of all of this, which you can see in the video or on GitHub, is a bare-bones Angular.js 1.6 app that allows you to sign-up and curate a list of movies:
All in all, this was - for me - a very exciting dive into thinking about and learning about Express.js middleware. The middleware are the basic building blocks of an Express.js application; and, while simple in nature, they can clearly be wired together to carry out complex logic and flows-of-control. I think I am really starting to understand how they work in an Express.js and Node.js application.
Want to use code from this post? Check out the license.
Reader Comments