Express director is a simple npm package to enable loading a directory as express routes.
This project requires NodeJS (version 14 or later) and NPM. Node and NPM are really easy to install. To make sure you have them available on your machine, try running the following command.
$ npm -v && node -v
7.24.0
v16.10.0
- Express Director
- Prerequisites
- Table of contents
- Installation
- Usage
- Contributing
- Versioning
- Authors
- License
To install:
$ npm install --save express-director
Or if you prefer using Yarn:
$ yarn add express-director
For example usage check out the tests for an example ts, cjs and mjs applications.
In your app.js
const startApp = async () => {
const app = express();
// add your favorite pre request middleware here
app.use(await loadDirectory({
// these fields are optional as sane defaults are provided, but you must pass an object even if you are not overriding any of the defaults.
controllerPath: path.join(process.cwd(), 'src', 'controllers'),
defaultControllerGenerator: () => {
renderer: ({res, data}) => res.send(data),
}
}));
// add your favorite error handling middleware here
return app;
};
startApp().then(app => app.listen(3000))
loadDirectory takes a context argument with several fields you can use to customize the behavior of express-director globally. Note that all of these are optional, but you must pass an object regardless of whether or not you intend to override anything.
This is the path to your controllers. Defaults to the current working directory/src/controllers. If your controller root is in a different location you should make sure that this is set to the absolute path of that directory.
This is a function that receives the controllers filepath and returns an object that will be shallowly merged with your controllers to set default values across all your controllers as necessary. For details on the shape of this object check out the controllers section below. Note that the actual controller will override any conflicting values set here.
The swagger field is an object that will be shallow merged with the generated config from your controllers. You can use this to set human readable description/title fields for your api as well asmore specific security handlers and such as necessary. Not that since this is a shallow merge anything under the paths key will be overwritten. You can set this to a falsy value if you wish to disable the autogenerated swagger playground.
This is an array of controller processors that produce express middlewares to handle the behavior you want out of your controllers. Note that this is very advanced usage and should rarely be needed. If you choose to override this behavior be sure to include the default processors in the list otherwise you controllers might be missing behavior described below.
This Processor handles the prepareRouter key on controllers. See below for detailed documentation.
This Processor handles the schemas key on controllers. See below for detailed documentation.
This Processor handles the versionBy, versions, handler and renderer key on controllers. See below for detailed documentation.
This processor handles the swagger key as an override/enhancement to auto generated swagger config.
For convenience of customization we also bundle some helpful processors that are not added by default Example of including a bundled processor:
// in your main file where express-directory is configured
import loadDirectory, { defaultProcessors, checkRequestField } from 'express-director';
import permissionProcessor from 'src/processors';
const startApp = async () => {
const app = express();
app.use(middlewareThatSetsRequestPermissions)
app.use(await loadDirectory({
controllerPath: path.join(process.cwd(), 'src', 'controllers'),
defaultController: {
renderer: ({res, data}) => res.send(data),
},
// note that if you do not include the default processors or include them out of order then YMMV with regards to the rest of the documentation. The only test case run as part of this project is to add new processors to the beginning of the config.
controllerProcessors: [checkRequestField('user'), ...defaultProcessors]
}));
// add your favorite error handling middleware here
return app;
};
startApp().then(app => app.listen(3000))
The check request feld processor allows you to check that a field on the express req object matches a value in your controller definition. It requires you to pass the field name you wish to check against. Nested fields can be accessed using . notation (eg "user.id") you can also pass a second argument to customize the name of the controller key to use. By default the first parameter is also used for the controller key name.
So if you initialize it like so:
// in your main file where express-directory is configured
import loadDirectory, { defaultProcessors, checkRequestField } from 'express-director';
import permissionProcessor from 'src/processors';
const startApp = async () => {
const app = express();
app.use(middlewareThatSetsRequestPermissions)
app.use(await loadDirectory({
controllerPath: path.join(process.cwd(), 'src', 'controllers'),
defaultController: {
renderer: ({res, data}) => res.send(data),
},
// note that if you do not include the default processors or include them out of order then YMMV with regards to the rest of the documentation. The only test case run as part of this project is to add new processors to the beginning of the config.
controllerProcessors: [checkRequestField('user'), ...defaultProcessors]
}));
// add your favorite error handling middleware here
return app;
};
startApp().then(app => app.listen(3000))
Then you can write a controller with the user key to verify that at request time req.user is set to "myusername" like so:
export default {
user: "myusername",
handler: (req, res, next) => {
return { hello: 'world'};
}
}
If the field does not === the value passed then a 403 error will be returned.
The check request feld contains processor allows you to check that a field on the express req object contains a value in your controller definition. If the express req field is not an array this is equivalent to using checkRequestField. It requires you to pass the field name you wish to check against. Nested fields can be accessed using . notation (eg "user.id"). you can also pass a second argument to customize the name of the controller key to use. By default the first parameter is also used for the controller key name.
So if you initialize it like so:
// in your main file where express-directory is configured
import loadDirectory, { defaultProcessors, checkRequestFieldContains } from 'express-director';
import permissionProcessor from 'src/processors';
const startApp = async () => {
const app = express();
app.use(middlewareThatSetsRequestPermissions)
app.use(await loadDirectory({
controllerPath: path.join(process.cwd(), 'src', 'controllers'),
defaultController: {
renderer: ({res, data}) => res.send(data),
},
// note that if you do not include the default processors or include them out of order then YMMV with regards to the rest of the documentation. The only test case run as part of this project is to add new processors to the beginning of the config.
controllerProcessors: [checkRequestFieldContains('user.permissions', 'permission'), ...defaultProcessors]
}));
// add your favorite error handling middleware here
return app;
};
startApp().then(app => app.listen(3000))
Then you can write a controller with the permission key to verify that at request time req.user.permissions contains "admin" like so:
export default {
permission: 'admin',
handler: (req, res, next) => {
return { hello: 'world'};
}
}
If the field does not contain the value passed then a 403 error will be returned.
Example custom processor:
// in a file called permission-processor.js
const processor = ({path, controller}) => {
if (controller.permission) {
return {
handlers: [
async (req, res, next) => {
try {
if(req.permissions.includes(controller.permission) {
return next()
}
res.sendStatus(403)
} catch (e) {
next(e);
}
},
],
swagger: {
description: `This endpoint requires the ${controller.permission} permission`
}
}
}
return [];
}
export default processor
// in your main file where express-directory is configured
import loadDirectory, { defaultProcessors } from 'express-director';
import permissionProcessor from 'src/processors';
const startApp = async () => {
const app = express();
app.use(middlewareThatSetsRequestPermissions)
app.use(await loadDirectory({
controllerPath: path.join(process.cwd(), 'src', 'controllers'),
defaultController: {
renderer: ({res, data}) => res.send(data),
},
// note that if you do not include the default processors or include them out of order then YMMV with regards to the rest of the documentation. The only test case run as part of this project is to add new processors to the beginning of the config.
controllerProcessors: [permissionProcessor, ...defaultProcessors]
}));
// add your favorite error handling middleware here
return app;
};
startApp().then(app => app.listen(3000))
Adding your own custom processors allows you to extend the functionality of your controllers to support other nice opt in feature like authentication, logging, audit trail, permissions etc that the library does not yet support. Please feel free to add an issue to the repository if there is some controller key you would like to see added or a custom processor you think the community would benefit from.
To add a route to your application simply add a file with the matching http verb as the name at the path you would like it to be served. So if you wanted to support a POST request to localhost:3000/widget you would create:
src/controllers/widget/post.js
It is worth noting here that the following keys and documentation relating to them only hold true if the configuration for loadDirectory includes all the default processors.
The swagger key is where you can specify specific openapi keys for this endpoint. Note that usually this is only necessary for odd usecases. Information from your schemas, the route represented by the file path, and the method from the filename are all captured and added to the playground automatically. Typically you would use this to set a custom set of responses or other information not captured in the standard controller configuration.
There are a few generally accepted ways to version api endpoints. If you wish to version your endpoints by url then you can ignore this field and simply add directories with the name v1, v2 etc as necessary to update the paths. However for some types of versioning like header or cookie based versioning this is insufficient. To enable arbitrary versioning methodologies you can set the version by field to a function that takes in the request and returns the version string you should use:
export default {
// version based on a header
versionBy: (req) => req.get('x-api-version')
}
Note that the req object here is the express request and this function can be async. This should allow you access to an context you require for proper versioning. It is also reccommended, but not required that you set this on your default controller as your api may become difficult to use if multuiple versioning schemes are used for different paths.
The returned version string should match a key in the versions field which will cause the overrrides in that value to be applied to the controller.
See versionBy for an explanation of the reason for this field. You may provide an object mapping version strings to controller overrides so that you can host multiple versions of an endpoint at the same http path.
export default {
versions: {
v1: {
handler: (req, res) => res.send('I'm in version 1')
},
v2: {
handler: (req, res) => res.send('I'm in version 2')
}
}
// if you would like to return a 404 instead when no version matches simply omit the following line
handler: (req, res) => res.send('No version was matched so I we fell back to the default')
}
A controller should export a handler function like so:
export default {
handler: (req, res, next) => {
// do whatever you would do in a normal express handler here
// but if you throw an exception it will be caught and forwarded to any error middleware you have defined
// you can also return a value here and it will be passed to the renderer of the controller or default renderer from the global initialization.
return { hello: 'world'};
// by default the above is equivalent to:
// res.send({hello: 'world'});
}
}
In order to use a custom renderer you can add the renderer key to your controller. The renderer receives a context object that includes the result of your handler. These renderer allow you to standardize output formats by sharing renderer functions across multiple controllers. It is recommended that any custom renderer you write be placed in src/renderers, but this is not enforced by the library in any way.
const controller = {
handler: () => ({ hi: 5 }),
// note here that the path here is the relative path from the cwd of the controller file this is useful if you want to grab related resources based on the path of the controller.
renderer: ({req, res, path, data}) => res.send({ count: data.hi })
};
export default controller;
In order to validate your input params you can use the schemas key to apply a JSON schema to the body, params, or query object. The server will return a 400 error with the ajv errors object if the request does not match any of your schemas.
It's worth noting that if you aren't using some form of body parser the body schema might have unpredictable effects as validation is applied to the field on the express request object and not the base data.
export default {
schemas: {
body: ajvSchemaForBody,
params: ajvSchemaForParams,
query: ajvSchemaForQuery,
},
// handler etc goes here
}
Using a schema will strip any extra fields from the relevant key on the request object and so you should make sure to add complete schemas for your controllers
If you do specify schemas then the request.validatedData object will be available with an object containing the merged content of your schema fields.
Setting middleware on a path and it's children can be done using the prepareRouter key on an exported controller:
// all.js
export default {
prepareRouter: (r) => {
r.use((req, res, next) => {
res.append('nonsense', 'true');
next();
});
},
};
Some caveats to this approach are that in express the order that middleware is added is very important. It is recommended that you set any middleware that you intend to use in the all.js file as it will be the first thing loaded. This way middleware applies to everything below it without weird edge cases. However if your folder contains only one file it may be easier to read with all configuration in the same file, so it is an option to configure the router from any controller.
For clarification if load order is causing problems your files in a particular folder are processed in the following order:
- All .js files in the directory in lexical order
- All folders in reverse lexical order depth first
In order to facilitate good typing in projects using this package the DefaultController type is exported so that a file can opt in to typechecking in the following way. This will verify all controller keys being set correctly. Please note that if you overwrite the controllerProcessors config option you will have to build your own controller type based on the controller format expected by your list of processors. In order to simplify that process types are exported for each processor that you can combine to create a new controller type. but for most common usecases the DefaultController should suffice.
import { DefaultController } from 'express-director';
const controller: DefaultController = {
handler: () => ({ hi: 5 }),
};
export default controller;
If your endpoint is using schemas then you can pass the appropriate types for your schemas so that ajv typechecking is enabled for your schemas and the validatedData field is detected as the correct type.
import { DefaultController } from 'express-director';
type Params = {
id: number;
}
type Body = {
firstName: string;
lastName: string;
}
type Query = {
middleName: string;
}
const controller: DefaultController<Query,Body,Params> = {
schemas: {
params: {
type: 'object',
properties: {
id: { type: 'number', minimum: 100000 },
},
required: ['id'] as const,
},
query: {
type: 'object',
properties: {
middleName: { type: 'string', minimum: 1 },
},
required: ['middleName'],
},
body: {
type: 'object',
required: ['firstName', 'lastName'],
properties: {
firstName: { type: 'string', minimum: 1 },
lastName: { type: 'string', minimum: 1 },
},
},
},
// req.validatedData comes back as the merged type of your three schemas
handler: (req) => req.validatedData.firstName,
};
export default controller;
If you are using a custom renderer it will take into account the data field being of your handler result type:
import { DefaultController } from 'express-director';
type HandlerResult = {
hi: number;
}
const controller: DefaultController<null,null,null, HandlerResult> = {
handler: () => ({ hi: 5 }),
renderer: ({res, data}) => res.send({data: { count: data.hi }})
};
export default controller;
- Fork it!
- Create your feature branch:
git checkout -b my-new-feature
- Add your changes:
git add .
- Commit your changes:
git commit -am 'Add some feature'
- Push to the branch:
git push origin my-new-feature
- Submit a pull request ๐
We use SemVer for versioning. For the versions available, see the tags on this repository.
- Sean Ferguson - Initial work - Sean Ferguson
See also the list of contributors who participated in this project.
MIT License ยฉ Sean Ferguson