Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created April 12, 2017 13:44
Show Gist options
  • Save bennadel/ca97d3e107b9b2ecb02a874cd9747421 to your computer and use it in GitHub Desktop.
Save bennadel/ca97d3e107b9b2ecb02a874cd9747421 to your computer and use it in GitHub Desktop.
An Express.js Learning Experiment - Porting FW/1 Ideas Into A Node.js Application
// 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;
// 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"
}
)
);
// 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();
}
}
}
};
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;
// 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;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment