express-director
TypeScript icon, indicating that this package has built-in type declarations

0.11.0 • Public • Published

npm version

Express Director

Express director is a simple npm package to enable loading a directory as express routes.

Prerequisites

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

Table of contents

Installation

To install:

$ npm install --save express-director

Or if you prefer using Yarn:

$ yarn add express-director

Usage

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))

Configuring loadDirectory

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.

controllerPath

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.

defaultControllerGenerator

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.

swagger

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.

controllerProcessors

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.

Default Processors
prepareRouter

This Processor handles the prepareRouter key on controllers. See below for detailed documentation.

processSchemas

This Processor handles the schemas key on controllers. See below for detailed documentation.

processHandlerAndResponder

This Processor handles the versionBy, versions, handler and renderer key on controllers. See below for detailed documentation.

processSwagger

This processor handles the swagger key as an override/enhancement to auto generated swagger config.

Bundled Processors

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))
checkRequestField

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.

checkRequestFieldContains

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.

Creating custom processors

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.

Defining Controllers

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.

swagger

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.

versionBy

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.

versions

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')
}

handler

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'});
  }
}

renderer

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;

schemas

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.

prepareRouter

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:

  1. All .js files in the directory in lexical order
  2. All folders in reverse lexical order depth first

Typescript types

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;

Contributing

  1. Fork it!
  2. Create your feature branch: git checkout -b my-new-feature
  3. Add your changes: git add .
  4. Commit your changes: git commit -am 'Add some feature'
  5. Push to the branch: git push origin my-new-feature
  6. Submit a pull request 😎

Versioning

We use SemVer for versioning. For the versions available, see the tags on this repository.

Authors

See also the list of contributors who participated in this project.

License

MIT License © Sean Ferguson

Readme

Keywords

none

Package Sidebar

Install

npm i express-director

Weekly Downloads

0

Version

0.11.0

License

MIT

Unpacked Size

68.6 kB

Total Files

18

Last publish

Collaborators

  • seanferguson