ExpressHappiness is a rich framework for creating web apps and services, built on top of the Express framework.
We all use and love Express. When it comes to large and complex apps, some times the maintainability of them becomes hard as we keep on adding routes, handle errors and permissions.
ExpressHappiness provides a way to develop robust and maintainable apps or REST APIs using Express.
The main features of the framework are:
- The full routes tree is defined in a single object, in a JSON-like manner
- Error handling is defined in the exact same manner in a single file. You can either define your own error codes or define the way that they'll be handled in the errors configuration file and trigger them any time from any part of your code, letting the framework handle them the way you define.
- Define permissions to each route by specifying the access group(s) that each route belongs to and define middlewares for groups of routes, not for each route separately
- Validate the params of each call by defining their characteristics on the routes definition file
- Automatically generate your rest api documentation
- Work with mock data, by just putting a file in a specific folder and setting the
mock
parameter to true on the route's definition - Add function hooks to any error type
- and many more...
Released under the WTFPL license by Andreas Trantidis @AndreasTrantidi
$ npm install express-happiness
Every ExpressHappiness project consists of a specific set of files that define the project's routes and behaviour.
These files are:
- Routes Tree Configuration: On this file you'll put / define all
of the supported routes of your app with all of their details. These details include the method of the route
(get / post / put / delete), the parameters of the route (parameter type, validation rules) and you can also enable / disable
the mock operation.
For each route you can define an alias name and also the group(s) that this route belongs to. The "groups" that the routes belong to are custom groups, with custom names. The groups that a route belongs to define the middlewares that will be applied to it. More details about grouping and group-specific middlewares will be defined later on. - Reusable Params File: There are cases where many routes have the exact same parameter, with the exact same type and validation rules. In order to avoid rewriting the same parameter definition again and again throughout all of these routes you can define these params once and just reuse them to any of the routes. The file that holds the "reusable params" definitions is the Reusable Params File. These type of parameters are called "reusable" params and are defined on the Reusable Fields File
- Error Handling Configuration File: At any point of the middlewares chain of any call you can
trigger errors. Errors of custom types, decorated with custom details. Express Happiness provides an error handling
mechanism that automatically takes care this errors. The way each custom type's error will be handled is defined on this file.
The developer has the ability to define whether and what is going to be written to the error.log file,
what code should be sent back to the client (200, 400, 401, etc) and what data will be served. Finally, on each of these
error types, the developers can define a set of hook functions to be executed (email send, log to db, whatever).
All these error handling parameters are defined on the Error Handling Configuration File. - error log: Is just a plain text file holding all logs printed out by the Error Handling mechanism
- Controller Functions: This file defines the function that should be executed on each route.
The main concept here is not to define / write the body of these functions within this file but to have a "mapper"
endpoint though which anyone can easily find out what's going on, on any route.
The association between routes and controller functions is done by an associative array, each key of which is a string, either the full route definition or the route alias and each value the corresponding controller function. As mentioned, routes' aliases can be defined on the Routes Tree Configuration File.
Each parameter, no matter its type, has the following structure:
Code Snippet 1. Parameter's basic structure
param: {
"key": "the-key-name",
"type": "one-of-the-supported-types",
"humanReadable": "short title of the param",
"description": "A human-readable description of the parameter",
"mandatory": "boolean-true-or-false"
}
Not all of these characteristics are mandatory nor are they all. For each parameter type, special characteristics apply. For example, for type "int", the definition object of the param may (optionally) include "min" and "max" keys, etc.
The list of the supported parameter types is:
Table 1. Supported parameter types
int | Integer |
date | Date |
oneof | The value must be one of the specified |
boolean | Boolean |
numeric | any number |
string | String |
array | Array |
object | Object |
The full list of the supported attributes for each param are listed below:
Table 2. Supported param attrs
Attribute key | Mandatory | Description | Applies to |
---|---|---|---|
key | yes | the key name of the parameter. For example, if on a route you expect for a param with the name "x", then the key of this param is "x" | all types |
type | yes | the type of the parameter. Might be one of: "int", "date", "oneof", "boolean", "numeric", "string", "array" and "object" | |
humanReadable | no | a short title of the parameter. This is used by the parameters validator on the error texts that exports on validation failures | all types |
description | no | the description of the parameter. It will be used on the auto-generated documentation of your API | all types |
mandatory | no | defines whether the parameter is mandatory or not. If it's missing the parameter is not mandatory | all types |
validationFailureTexts | no | An object containing the text messages that will sent back to the client for all (or any) cases that the validation fails. For example, if a mandatory field is missing the text that will be sent back to the client is defined on the key "mandatory" of this object, etc. The supported keys of this object are listed on the very next table that follows. | all types |
min | no | the minimum value (including this) that a param can accept | int, numeric |
max | no | the maximum value (including this) that a param can accept | int, numeric |
minChars | no | the minimum characters that a string's type parameter's value might have | string |
maxChars | no | the maximum characters that a string's type parameter's value might have | string |
validationString | yes | the format that a date value should have. The formats are identical as on moment.js | date |
minLength | no | the minimum length an array-type value should have | array |
maxLength | no | the maximum length an array-type value should have | array |
acceptedValues | yes | an array listing all the acceptable values for this param | oneof |
regexp | no | a regular expression to check the provided value against | string |
keys | no | an object that specifies the expected structure of an object type parameter | object |
The supported keys of the validationFailureTexts are:
Table 3. validationFailureTexts supported attributes
Key | In case... |
---|---|
type | the type of the field's value is not of the defined/expected field's type |
mandatory | a mandatory filed is missing |
min | the either of type int or numeric field's value is lower than the minimum accepted, according to the min attribute of the field's definition |
max | the either of type int or numeric field's value is greater than the maximum accepted, according to the max attribute of the field's definition |
minChars | the characters of the provided string are less than the minimum accepted, according to the minChars attribute of the field's definition |
maxChars | the characters of the provided string are more than the maximum accepted, according to the maxChars attribute of the field's definition |
validationString | the value provided for the field of type date is not compatible with the validationString of the field's definition |
minLength | the length of the array passed on the specific field's value is lower than the minimum accepted, according to the minLength attribute of the field's definition |
maxLength | the length of the array passed on the specific field's value is greater than the maximum accepted, according to the maxLength attribute of the field's definition |
acceptedValues | the value of the, of type oneof, field is not present on the acceptedValues array on the field's definition |
regexp | the string provided does not comply with the regular expression from the regexp property of the field's definition |
So, as an example:
Code Snippet 2. Parameter's structure including validationFailureTexts object
param: {
"key": "user_age",
"type": "int",
"humanReadable": "Age",
"description": "The age of the user",
"mandatory": "true",
"min": 18,
"validationFailureTexts": {
"mandatory": "Please provide your age",
"min": "Sorry, you must be at least 18 years old"
}
}
In such case if the submitted value for the "age" is under 18 (let's say 17) then on the "errors" array of the response there will be the
text "Sorry, you must be at least 18 years old". If the key "min" was missing from the "validationFailureTexts" object (of if
the "validationFailureTexts" was missing at all on the field's definition), the error text that would be included on the errors array would be the default:
"Age must be greater or equal to 18. 17 provided."
Finally, in the case that there was the "humanReadable" key missing from the field's definition, then the error text would be:
"user_age must be greater or equal to 18. 17 provided."
Now it's time to proceed with your Reusable Params File.
The reusable params file is just a module that exports an object. This object has a number of keys. Each
key holds another object which represents the definition of a parameter.
The name of the key is the way you'll refer to the parameter from the Routes Tree Configuration File.
So, let's assume that you want to define two reusable parameters on this file, parameter "id" and parameter
"cat_id". Here's a possible / valid definition of these two params in the Reusable Params File:
Code Snippet 3. Example of Reusable Params File
module.exports = {
id:{
key:'id',
type:'int',
humanReadable: 'organization id',
description:'The Organization from which data is requested',
mandatory:true
},
category: {
key:'cat_id',
type:'oneof',
humanReadable: 'Product category',
description:'The category of the product',
mandatory:false,
acceptedValues: ['shoes', 'clothes'],
validationFailureTexts: {
acceptedValues: 'Sorry, only shoes or clothes categories are supported'
}
}
}
Though, each of these params have a "key" attribute. This is the name of the parameter as we expect it during the actual calls that our service will receive. As mentioned, one of the supported param types is the "object". Also, one of the supported on object-type parameter's attributes is "keys".
You can use the "keys" attribute of an object parameter in order to define (to any depth) the expected structure of the object and not only this. You can apply / define validation rules of each of them, no matter which depth it is. Here's an example:
Let's suppose that on a specific call we expect for a parameter with the name "user_data" which will hold the user's information in a well defined structure. Both the structure of the expected object and the validation rules that apply to it are made obvious through the following parameter definition:
Code Snippet 4. Example of an object parameter
user:{
key:'user_data',
type:'object',
humanReadable:'User data',
description:'Full user information',
mandatory:true,
keys:{
gender:{
type:'oneof',
mandatory:true,
acceptedValues: ['male', 'female'],
validationFailureTexts: {
mandatory: 'Please specify your gender',
acceptedValues: 'Please pick between male and female'
}
},
country:{
type:'oneof',
acceptedValues:['Greece', 'Sweden', 'Australia', 'Romania']
},
name:{
type:'object',
keys:{
first:{
mandatory:true,
type:'string'
},
last:{
mandatory:true,
type:'string',
validationFailureTexts: {
mandatory: 'Please specify your last name'
}
},
middle: {
mandatory: false,
type: 'string'
}
}
}
}
}
The "keys" param is an object which holds a set of keys. These keys represent / map the expected keys of the object.
The attributes of each one of these keys are identical with the attributes used in plain (not-object) parameters. All attributes of table 2 apply just fine to all nested keys of all objects. The only difference between the definition of a plain parameter and the definition of an object's key is that on the object key's definition there's no "key" attribute. The "key", that is the expected name of the key on the provided object during a call, is identical to the name of the key itself. E.g. on the specific example we expect the user_data.name.first to be present.
This has been implemented this way for two reasons:
- There's no need and no way to refer to a nested key from services such as the FieldsLoader (we'll see about that later on)
- We wanted to mimic the structure of the object in an one-to-one mapping
In order to be able to reuse the parameters we defined in the Reusable Params File, we need the (built in) FieldsLoader module. For this the structure / format of our Routes Tree Configuration File should look like this:
Code Snippet 5. Basic structure of the Routes Tree Configuration File
module.exports.conf = function(fieldsLoader){
return {
routes:{
// here go all of the supported routes of the application
}
}
};
You might have noticed the term "Tree" in the name of the "Routes Tree Configuration File". Before further analysing the
way we can define your routes in here, it's good to mention a few things regarding its concept.
In an application there might be routes of the same url (e.g. /a/b) but of different types (e.g. GET, POST). Also, in
an application there might be routes that do something and also subroutes of it that do something else. For example,
there might be the route /a/b and also the route /a/b/c.
The way the routes are defined on the Routes Tree Definition File respect both of the above facts. On any given path you
can separately define the various supported call types and then you can continue deeper defining the supported subroutes of it.
In the specific example here's a possible / valid Routes Tree Configuration File:
Code Snippet 6. Routes Tree Configuration File including paths and endpoints
module.exports.conf = function(fieldsLoader){
return {
routes:{
a:{
subRoutes: {
b: {
get: {
// here goes the GET /a/b route definition
},
post: {
// here goes the POST /a/b route definition
},
subRoutes: {
c: {
get: {
// here goes the GET /a/b/c route definition
}
}
}
}
}
}
}
}
};
In general, the tree that we are defining here consists of paths and endpoints. Everything starts on the "routes" parameter
of the returned object. "a" represents a path, the path "/a". This path has subRoutes, like "/a/b". So, we define these
subroutes on the "subRoutes" parameter of it.
"b" is a path itself as well. Following the object's structure, is obvious that it represents the path "/a/b". A path might
have (or might not have) endpoints. For example the path "/a" does not have any endpoints. Though, path "/a/b" has get and post
endpoints.
The endpoints are defined by the use of the corresponding key (one of "get", "post", "put", "delete"). The endpoints are the "leafs"
of this tree while the paths act as the branches.
A branch can have sub-branches and leafs. A leaf cannot have neither sub-branches nor sub-leafs, and that's we call then "endpoints".
Within the "subRoutes" parameter of any path we can only define paths.
On any endpoint we can define the fields that we're expecting.
Here's a table with all the supported attributes of each element:
Table 4. Path's supported attributes
Attribute name | Description |
---|---|
groups | an array containing all the groups the path belongs to |
subRoutes | an object which keys will represent the next sub-section of the path |
any of the supported call types (one of "get", "post", "put", "delete") | defines an endpoint to the specific path. E.g. by placing the "get" key on a path it's value must be an object defining the characteristics of the specific endpoint |
Table 5. Endpoints's supported attributes
Attribute name | Description |
---|---|
groups | an array containing all the groups the path belongs to |
alias (optional) | a string representation / alias name of the specific endpoint |
description (optional) | a human readable description of the endpoint. This will be used on the auto-generated documentation of your API |
fields (optional) | an array containing all the fields that are expected on this endpoint. It will be used both by the validator and the auto-generated documentation process. If your endpoint does not expect any parameters or you don't want to apply any params validation you can just skip it |
mock | in cases you want a specific endpoint to serve mock data just put the mock attribute and set it to true |
The grouping mechanism follows a waterfall inheritance mechanism, just like inheritance works on html elements. Every child inherits by default the groups of its closest parent. If its closest parent has no group definition then that means it inherits its own closest parent's groups etc.
For any element in our tree (either endpoint or path), this inheritance goes all the way up until we have an explicit groups definition.
So if you have endpoints / routes defined on /admin/* and all belong to a specially restricted area of your application, where admin authentication should take place, the only thing you have to do is to define on the /admin path the groups equal to ['admin']. All of the child paths and endpoints of it will inherit this groups definition.
At any point, at any path or endpoint, you can explicitly define the groups attribute. By this way, any inherited value of the attribute "groups" will be ignored and the explicitly defined will prevail.
By organizing the endpoints into groups on the Routs Tree Configuration File gives us the opportunity apply middlewares specially developed for each of these groups. We'll see more details on this later on.
Finally, as you might have noticed, groups is an array. That means that for an endpoint belonging to more than one groups, all middlewares of all groups it belongs to will be applied in the sequence the group names have been placed within the array. The "alias" attribute of each endpoint gives us a way to refer to this endpoint from any other part of the Express Happiness ecosystem. The most important usage of this "alias" has to do with the mock operation. As mentioned on table 5, along with the "alias" attribute each endpoint (optionally, default = false) might have a "mock" attribute set to true.
We will analyse mock operation in details in a following part of this document, though keep just one thing in mind for now. If any of the endpoints has been set to server mock data then a file containing the mock response should be present somewhere in your hard drive. The folder that contains all mock files is defined on ExpressHappiness invocation, though the actual name of the file, for each endpoint, should be identical with its alias. The fields is an array containing all the fields that the specific endpoint supports / expects. As explained, all of the fields / params have a very specific definition pattern that has been analyzed on the Reusable Params File section.
The fields array might "load" fields from the reusable fields or can either include newly defined params.
In order to load a parameter from the params defined on the Reusable Params File you should use the method "getField" of the FieldsLoader module that comes with the ExpressHappiness framework.
Here is a complete example, using the fields defined on code snippet 3:
Code Snippet 7. Routes Tree Configuration File example with fields loading
module.exports.conf = function(fieldsLoader){ // mind the "fieldsLoader" argument
return {
routes:{
a:{
subRoutes: {
b: {
get: {
alias: 'a_b_get',
description: 'a dummy description of a dummy endpoint',
fields: [
fieldsLoader.getField('id'),
fieldsLoader.getField('category', {
mandatory:true
}),
{
key: 'size',
mandatory: true,
type: 'numeric'
}
]
}
}
}
}
}
}
};
As you can see, on the GET "/a/b" endpoint we have defined three fields:
- by loading the "id" field. The resulting field is identical as the definition of the field on the Reusable Params File (code snippet 3)
- by loading the "category" field and passing second argument on getField. The "getField" method of FieldsLoader takes a second optional attribute. This attribute is an object. All own keys of this object that are included in the supported params attrs list (table 2) will overwrite the ones defined on the Reusable Params File.
- A totally new parameter. There are cases where a parameter might appear only once and only in one endpoint. In such cases there's absolutely no need to define it on the Reusable Params File but, instead, you can define it directly on the "fields" array of the endpoint.
Here's a simple demonstration on how to define routes including dynamic params:
Code Snippet 8. Dynamic Route Params
module.exports.conf = function(fieldsLoader){ // mind the "fieldsLoader" argument
return {
routes:{
a:{
subRoutes: {
":b": { // check the ":b" on the subRoute key
get: {
// endpoint definition
}
}
}
}
}
}
};
The way each error type should be treated by the application is defined on the Error Handling Configuration File.
The basic structure of this file is the following:
Code snippet 9. Error Handling Configuration File Basic Structure
exports.errors = {
undefinedError:{
log:true,
humanReadable: 'Unresolved error code',
sendToClient: {
code:500,
data: 'ErrCode: 1 - There was an error fulfilling your request at the moment. Please try again in a while'
},
hooks:[
// here you can put whatever you want
]
},
'404':{
log:true,
sendToClient: {
code:404,
data:'Invalid route'
}
},
'my_custom_error_code':{
log:false,
sendToClient:{
code:500,
data:'There was an error fulfilling your request'
}
}
}
The Error Handling Configuration File exports an object. Each object key represents the "type" of the error. So, in our
example, if an undefined error gets triggered by the app, our application will log it, it will write to the log file the
text "Unresolved error code" and the client will receive a 500 code along with the body "ErrCode: 1 - There was an error
fulfilling your request at the moment. Please try again in a while".
Let's dive deeper and see which attributes are supported for each error type defined on this file.
Table 6. Supported attributes on error configuration
Attribute name | Type | Mandatory | Description |
---|---|---|---|
log | boolean | no (default: false) | If log is set to true, this error will be logged to the error.log file |
humanReadable | string | no | a human readable description of the error. This is what it will get logged to the error.log file if log is set to true |
sendToClient | object with two keys: code and data | no | with the use of this attribute you can define the response code and the response body that will be sent back to the client. If you want to send to the client the details of the error object just put there (in quotes) 'err.details' |
hooks | array | no | a list of functions to be executed whenever an error of this type occurs. The hook functions should take 3 arguments:
|
Code Snippet 10. Triggering errors
var err = new Error();
err.type = 'my_custom_error';
err.details = 'The details of the error. Might be either a string, such as this, or any other data type (object, array etc)';
return next(err);
In order for this part of code to work you must have the "next" function available.
Fot those who are not that familiar with "next" function, please have a look here.
Once you trigger this event the rest is on Express Happiness to take care of, according to the Error Handling Configuration File.
- undefinedError It's triggered for errors that don't belong to any of the defined errors. It's default configuration
is:
Code Snippet 11.1. Default undefinedError configuration
{ log:true, humanReadable: 'Unresolved error code', sendToClient: { code:500 } }
- invalidAttrs It's triggered on parameters validation failure. It's default configuration
is:
Code Snippet 11.2. Default invalidAttrs configuration
{ log:true, humanReadable: 'Invalid attributes passed', sendToClient: { code:400, data:'err.details' } }
- 404 It's triggered whenever a route does not exist. It's default configuration
is:
Code Snippet 11.3. Default 404 configuration
{ log:false, humanReadable: 'The requested resource does not exist', sendToClient: { code:404 } }
- noMockData It's triggered whenever a route that is defined to serve mock data, does not have any mock data file.
It's default configuration
is:
Code Snippet 11.4. Default noMockData configuration
{ log: true, humanReadable: 'There is no mock data available for this route yet', sendToClient: { code: 404, data:'There is no mock data available for this route yet' } }
- underDevelopment It's triggered in cases there's no controller function defined for a specific route.
It's default configuration is:
Code Snippet 11.5. Default underDevelopment configuration
{ log: false, humanReadable: 'A call to a route under development has been made', sendToClient:{ code:501, data:'This route is currently under development' }
What's been logged on this document, for each error, is:
Code Snippet 12. Error Line structure logged on the error log file
date | error code | route | human readable explanation of the error | error details
In order to do that you need two things:
- Configure the endpoint to serve only mock data
- Create a JSON file and put it in you mock files folder
Having our mock folder defined and set mock operation to on we can force an endpoint to serve mock data by:
- Putting in our mock files folder a file with the name --endpoint-alias--.json, where "--endpoint-alias--" is the alias of the endpoint defined on the key "alias" of the endpoint on the Routes Tree Configuration File
- Setting the operation status of the endpoint to mock by setting the "mock" attribute of the endpoint on the Routes Tree Configuration File to true
- Or by passing the parameter "mock" equals to 1 during the call
Assuming that a call passes all the middlewares, all the checks and it's not on mock operation status it reaches to the point where something actual needs to take place, a controller function should take care of the call.
All controller functions are defined in a single file. As stated in the beginning of this document, the actual aim of this architectural decision is not, of course, to pack all the code within this file but more to have one single point where you can assign responsibilities and define the controllers of all of your routes in a clean and readable way.
This single file is called Controller Functions File and it looks like this:
Code Snippet 12. Example of Controller Functions File
It's just a mapping of all controller functions to the routes. As you can see from the example snippet 12, the Controller Functions File should export an array. An associative array the keys of which are alias name of each route and the values the corresponding controller functions of them.var adminFunctions = require('./functions/admin.js'); var userFunctions = require('./functions/user.js');
var functions = {};
functions['route-alias-1'] = adminFunctions.doSomething; functions['route-alias-2'] = userFunctions.doSomethingElse;
exports.functions = functions;
The controller function for each endpoint is in the exact same format as a simple controller function that you would define if you used express without Express Happiness. That is:
Code Snippet 13. Controller Function format
var my_controller_function = function(req, res, next){
// do your magic here
}
Code Snippet 15. Starting your Express Happiness Server
var app = express(); var router = express.Router();
var eh = new expressHappiness(app, router, { mockData:{ enable: true, folder: '/path/to/mockdatafolder', global: true }, reusableFieldsFile: '/path/to/reusableFields.js', errorFile: '/path/to/errors.log', errorsConfigurationFile: '/path/to/conf/errors.js', apiConfigurationFile: 'path/to/conf/restConf.js', controllersFile: '/path/to/controllerFunctions.js' });
eh.generate('/v1', { 'userAccess':[middlewareA, middlewareB], 'adminAccess':[middlewareC, middlewareD], 'eh-allRoutes':[middlewareX] }, { 'userAccess':[middlewareA, middlewareB], 'adminAccess':[middlewareC, middlewareD], 'eh-allRoutes':[middlewareX] } );
First thing to do is to create a new Express Happiness instance. This is done by calling "new expressHappiness" function. This function takes three parameters:
- app: the application initialized by the express() function invocation
- router: The Router object provided by express which we can get through express.Router() invocation
- configuration: a configuration object that sets the full Express Happiness environment up. Here we define the path of each file needed by EH and also the mock operation configuration.
After creating the EH instance we call the "generate" function of it. The "generate" function takes three parameters:
- route path: this is the base url that our application will support. For example if we are developing a REST API that listens to paths under /v1/ then we don't need to define this on the Routes Tree Configuration File. We can define it here by passing the '/v1' value on the first argument of the generate function
- pre-validation middlewares: On the middlewares chain that are been executed on each call on our application,
EH always includes a middleware that performs the params validation for the given endpoint. The second argument
of the "generate" function provides us the ability to define a series of middlewares we want to get executed before reaching
the params validation step. The middlewares defined here, most often have to do with authentication issues.
The way we define our middlewares provides us the ability to explicitly define which middlewares should be executed according the group that each route belongs to (see table 5). As mentioned, each endpoint might belong to one or more groups and this is defined by the "groups" attribute (which holds an array). So, in our example for all endpoints that belong to the 'userAccess' group the middlewareA and middlewareB functions will be executed before the middlewares chain reaches the params validation step. For all middlewares that belong to the 'adminAccess' group the middlewareC and middlewareD will be executed before the params validation middleware gets execute and so on.
If we want to define pre-validation middlewares for all of our endpoints, no matter the groups they belong to, we can use the "eh-allRoutes" key to define them. All middlewares included on this array are going to be executed, in the order they appear within the array, on all of our endpoints. - post-validation middlewares: The exact same things stand here. The third argument of the "generate" function expects an object that though its keys defines the middlewares to be executed after the params validation process, on our routes, depending on the groups they belong to.
The form of our middlewares should be the typical middleware form of express: function(req, res, next).
After defining all of your endpoints with types and parameters on the root of your application there will be the documentation of your API. So, for example, if you run your app on localhost, port 7000, on http://localhost:7000 you'll find an auto-generated REST API documentation page.