-
Notifications
You must be signed in to change notification settings - Fork 4
Home
Fast, opinionated, minimalist, and conventional REST API framework for node. (With Typescript Support ⭐)
- Introduction
- Quickstart
- Migrating from V3 to V4
- Routing
- The BaseController
- Routes and subroutes
- Express App Configuration
- Middleware configuration
- Centralized error handling
- Asynchronous request handlers and middleware
- Request validation using Celebrate
- Centralized authorization
- Auto-generated documentation with Swagger
- File upload example with
multer
- Example applications (Both TypeScript and ES5)
*Table of contents generated with markdown-toc
Expressive is a NodeJS REST API framework built on ExpressJs and best practices for smooth development. Features include:
- Templated Routing
- Write APIs with declarative endpoints (including nested endpoints) easily
- Pluggable middleware with built-ins
- Inject own middleware just like Express
- Built in middleware e.g. body-parser, cors, etc.
- Security middleware i.e. helmet
- Built in support for asynchronous request handlers and middleware
- No need to wrap every controller with a try/catch block. Any error thrown will be sent to a central error handler
- Implicit
next
function calls- If you do not declare the
next
variable in your handler function, then it will be called for you automatically after your handler executes. If you declare it as a function parameter, you can use it as you like.
- If you do not declare the
- API validation using celebrate with Joi schemas (Using Joi schemas also yields generated Swagger documentation!)
- Centralized error handling
- All errors thrown in controller functions will go into one user-defined error middleware function (can be defined with app constructor)
- REST standard responses e.g. 200, 201, 404 built-in with the BaseController class methods
- Centralized authorization
- By defining authorizer function at any level - app, subroute, or route
- Objective Authorization: By defining an authorizer object for each level, and centrally managing request authorization with one handler
- Doc generation through Swagger https://swagger.io/
- Request parameter docs are generated if
validationSchema
property is provided with Joi schemas! - Each endpoint can have an associated doc using Swagger syntax (JSON/JS), making doc writing easier and distributed.
- Swagger doc can be viewed in development at http://localhost:8080/docs
- Request parameter docs are generated if
- Monitoring with express-status-monitor
Install the package: npm i -S @siddiqus/expressive
Here is a basic example: *It is required for controllers to extend BaseController. This also allows for dependencies to be instantiated inside the constructor, which can be useful for testing.
import { Route, ExpressApp, BaseController, ExpressiveRouter } from '@siddiqus/expressive';
class HelloGetController extends BaseController {
handleRequest(req, res) {
this.ok({
hello: 'world'
});
}
}
const router: ExpressiveRouter = {
routes: [Route.get('/hello', new HelloGetController())]
};
const app = new ExpressApp(router);
const port = Number(process.env.PORT || 8080);
app.listen(port, () => console.log('Listening on port ' + port));
Run this node script will start an Express app on port 8080. A GET request on [http://localhost:8080/hello] will return the following JSON response
{
"hello": "world"
}
The ExpressJS app can be used from the express property of the app object e.g. app.express
*It is not recommended to write your whole application in one file like the one above. Please see the example
folder for a sample folder structure for your application.
V4 has two main differences from V3:
- Controller classes need to be initialized when creating a Route object, which allows for dependency injection.
- Route properties e.g. validationSchema, middleware, authorizer, doc etc. need to be set as class properties of the controller.
import { ExpressiveRouter, BaseController, Route } from '@siddiqus/expressive';
class HelloController extends BaseController {
// some implementation of handleRequest
}
const router: ExpressiveRouter = {
routes: [
Route.get(
'/hello',
HelloController, // controller is not initialized here
{
validationSchema: { }, // some validation schema object
authorizer: { }, // some authorizer object
middleware: [(req, res) => {}], // some middleware functions
doc: someDocJs // some swagger definition
}
)
]
}
import { ExpressiveRouter, BaseController, Route } from '@siddiqus/expressive';
// declare validationSchema, middleware, etc. as class properties
class HelloController extends BaseController {
validationSchema = { }, // some validation schema object
authorizer = { }, // some authorizer object
middleware = [(req, res) => {}], // some middleware functions
doc = someDocJs // some swagger definition
// some implementation of handleRequest
}
const router: ExpressiveRouter = {
routes: [
Route.get('/hello', new HelloController(dependencies)) // HelloController class needs to be initialized with dependencies if any
]
}
It is easy to create routes and nested routes using Expressive, with the focus being on each individual endpoint.
Each separate endpoint e.g. 'GET /users/:userId' is handled using a controller class, that extends the BaseController
class provided by Expressive. There is an 'abstract' method called handleRequest
that requires an implementation for your own controller. The Express request
, response
and next
objects are available in this method using this
. This handleRequest
method can be used as an async
function also. For example:
const { BaseController } = require('@siddiqus/expressive');
class GetUsersController extends BaseController {
constructor(usersRepository) {
this.usersRepository = usersRepository;
}
async handleRequest() {
const userId = this.req.params.userId; // get an id from the path parameter
const userData = await this.usersRepository.getUserById(userId);
this.ok(userData); // base methods available for HTTP responses
}
}
Here you'll notice that the request object is available with this.req
and you can send a JSON response using the this.ok
function. This function sends back the data with a 200
status code. There are more methods implemented for various HTTP actions e.g.
- created (201)
- accepted (202)
- noContent (204)
- badRequest (400)
- unauthorized (401)
- forbidden (403)
- notFound (404)
- tooMany (429)
- internalServerError (500)
Sometimes you will want to always wrap your request/response data e.g. response: { data: someData }
The BaseController class has static methods responseMapper
and requestMapper
which have the response data and request
object as a parameter respectively. The responseMapper
is used in the class methods e.g. this.ok(data)
etc. to always wrap your response the way you define it. The requestMapper
can be used to do any high level request mapping for your application. You can overwrite this in your startup files like app.ts
like so:
import { BaseController, ExpressiveRouter, Route } from '@siddiqus/expressive'
BaseController.responseMapper = (data) => {
return {
body: data,
}
}
BaseController.requestMapper = (req) => {
// here normalizePhone is a utility function
req.body.phoneNumber = normalizePhone(req.body.phoneNumber) || undefined;
return req;
}
const router = {
routes: [] // route list
}
const app = new ExpressApp(router);
-
The ExpressApp class takes a 'router' parameter in its constructor's first parameter. This 'router' object looks like this:
{ routes: [], subroutes: [] }
-
Each object in the routes array is an instance of the Route class. For example, we can use the Route class's GET method to create a GET endpoint like so:
const { Route } = require('@siddiqus/expressive'); const helloGetRoute = Route.get( '/some/path', // required - relative end path of endpoint new SomeController(), // required - a controller function or class );
Similarly, the class methods
post
,put
,delete
,patch
,head
, andoptions
are available for the Route class e.g.Route.post
. -
Each object in the subroutes array can be constructed using the
subroute
function, like so:const { subroute } = require('@siddiqus/expressive'); const router = { subroutes: [ subroute('/some/sub/path', someRouter) // 'someRouter' is another router object ] };
Yes, the router
object is recursive ;)
Let's say we want to create an API with the following routes:
- GET /
- GET /hello/
- GET /hello/users
- POST /hello/users
We need to define a router object as follows:
const { Route, subroute } = require('@siddiqus/expressive');
const helloRouter = {
routes: [
// with some predefined controller functions
Route.get('/', new GetHelloController()),
Route.get('/users', new GetUsersController()),
Route.post('/users', new CreateUsersController())
]
};
const apiRouter = {
routes: [
Route.get('/', new ApiRootController()) // some predefined controller
],
subroutes: [subroute('/hello', helloRouter)]
};
The ExpressApp class constructor's second parameter is a configuration object that looks like this:
{
(swaggerInfo = null), // this is an optional JSON with the basic swagger info detailed later
swaggerDefinitions, // this is an optional JSON for the swagger model definitions
(allowCors = false), // this uses the 'cors' npm module to allow CORS in the express app, default false
(corsConfig = null), // config for cors based on the 'cors' npm module, allows all origin by default
(middleware = null), // Array of express middlewares can be provided (optional)
(errorHandler = null), // express middleware function to handle errors e.g. function(err, req, res, next){}
(basePath = '/'), // Root path of the api, default "/"
(bodyLimit = '100kb'),
(helmetOptions = null), // options for the 'helmet' middleware
(celebrateErrorHandler = null), // optional, handler to replace default celebrate 'errors' middleware
(notFoundHandler = null), // optional, handler that runs after all routes. By default it returns 404 status with a message.
(authObjectHandler = null); // optional handler to manage authorization via authorizer objects. See 'Centralized authorization' below.
}
Expressive comes with some built-in middleware at the application level. Middlware can be defined at any level - the application level (top), the route level (individual endpoint), and the subroute level (group of endpoints). Bear in mind since the routes follow a hierarchical structure, the middleware will be executed in the order of:
- route level
- subroute level
- ExpressApp level
So please be aware of using the next()
function accordingly inside your middleware.
The Expressive app comes with the following built-in middleware:
- cors - Allow CORS requests
- express-request-id - Assign a unique ID for each request
- helmet - Add various HTTP headers for basic security
-
json
andurlencoded
from theexpress
module (previously deprecated frombody-parser
)
You can enable CORS through the cors module using Expressive like this:
const app = new ExpressApp(router, {
allowCors: true
});
This will allow CORS for all origins. If you want to configure CORS as per the module's documentation, you can do so with the 'corsConfig' parameter:
const app = new ExpressApp(router, {
allowCors: true,
corsConfig: {
origin: 'http://example.com',
optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
}
});
The helmet
middleware is enabled with default configuration, and can be configured manually through the ExpressApp constructor, like so:
const app = new ExpressApp(router, {
helmetConfig: {
// some helmet configuration, see https://www.npmjs.com/package/helmet
}
});
These are examples on how to declare middleware accross the Expressive application.
const { BaseController, ExpressApp } = require('@siddiqus/expressive');
class GetHelloController extends BaseController {
middleware = [someMiddlewareForHello] // some array of middleware to execute for this particular endpoint
handleRequest() {
this.ok({
hello: 'world'
})
}
}
const router = {
routes: [
Route.get('/hello', new GetHelloController())
]
};
const app = new ExpressApp(router);
const { subroute } = require('@siddiqus/expressive');
const router = {
subroutes: [
subroute('/hello', someHelloRouter, {
middleware: [] // some array of middleware to execute for all routes under /hello
})
]
};
const expressApp = new ExpressApp(router, {
middleware: [] // some array of middleware
});
Point to note, these middleware functions run right before the main request handler for the given route
To catch an error from any controller, pass an error handling function using the 'errorHandler' property in the options parameter for the ExpressApp constructor. For example:
const { ExpressApp } = require("@siddiqus/expressive");
function centralizedErrorHandling (err, req, res, next) {
res.status(500);
res.json({
message: "There was an internal error."
});
}
const app = new ExpressApp(router, {
errorMiddleware: centralizedErrorHandling // all errors go here, can also be an array
}
It is recommended that you define custom Error classes to throw from inside your controllers, and then handle them in a switch in your central error handler.
Expressive does some work under the covers to manage async request handlers as well as async middleware.
In any controller (that extends the BaseController
of course), you can declare the handleRequest
function as async, but this is optional.
So both of these are okay:
class SomeController extends BaseController {
handleRequest(req, res, next) {
// some non-async code
}
}
And this is also okay:
class SomeController extends BaseController {
async handleRequest(req, res, next) {
// some async code
}
}
This example shows both async and non-async middleware being passed to multiple levels e.g. routes and subroutes.
const someMiddlewareForGetHello = [
async (req, res, next) => console.log('from mid 1') || next(), // this works
(req, res, next) => console.log('from mid 2') || next(), // this works too
(req, res) => console.log('from mid 2') // this works too
]
class GetHelloController extends BaseController {
middleware = someMiddlewareForGetHello
handleRequest() {
this.ok({
hello: 'world'
})
}
}
const route = Route.get('/hello', new GetHelloController());
Notice how the three middleware functions are different:
- One is async, another is a normal one, but the other one does not have the
next
function parameter used, but this is still okay because Expressive calls it internally - These cases are handled by Expressive internally and allows you to declare middleware in a flexible way
Expressive uses celebrate for API endpoint validations. A schema can be added to any endpoint using the 'validationSchema' class property of a controller.
const { Route, Joi, BaseController } = require('@siddiqus/expressive');
class GetUserByIdController extends BaseController {
validationSchema = {
params: {
userId: Joi.number().required()
},
body: {}, // keys with Joi schema
query: {},
headers: {} // and others based on celebrate's documentation
}
handleRequest() {
this.ok({
some: 'user'
})
}
}
const getUserById = Route.get('/users/:userId', new GetUserByIdController());
You can define an authorizer
property at the app, subroute or route level (in case of multiple definitions, they will be executed in that order respectively). Here are the examples:
class SomeController extends BaseController {
authorizer = (req, res) => {} // authorizer function for this particular route
// handleRequest is implemented, assume
}
const expressiveRouter = {
routes: [
Route.get("/users", new SomeController()),
],
subroutes: [
{
path: '/products',
router: productRouter, // some other defined Expressive router
authorizer: (req, res) => {} // authorizer function for all routes under /products/*
}
]
}
const app = new ExpressApp(expressiveRouter, {
authorizer: (req, res, next) => { // authorizer function for the whole app
})
While defining a function at every level gives you granular control over your endpoint authorization, Expressive gives you another way: authorizer objects
This implementation can be summarized as:
For an endpoint [x], there is a criteria [y] that describes who is authorized to access the endpoint.
You can use this authorization mechanism by defining an authObjectHandler
function as a parameter in ExpressApp, with any authorizer
property at any level being defined as an object, a string, or an array of those. Defining an authorizer
property without declaring the authObjectHandler
will throw an error while initializing the app. Here is an example.
- Let's say we define an endpoint with an authorizer
class SomeController extends BaseController {
authorizer = {
permissions: 'some-permission-criteria'
}
}
const expressiveRouter = {
routes: [
Route.get('/hello', new SomeController())
]
};
- And we have declared our
authObjectHandler
inExpressApp
params
import { Handler } from '@siddiqus/expressive';
export const authObjectHandler: Handler = (req, res, next) => {
const user = req.user; // this object is allowed for any user data
if (!user) {
return res.json(403).json({ message: 'unauthenticated' });
}
const authorizer = req.authorizer; // req.authorizer is a flattened array of all authorizer objects
// in our case, req.authorizer = [{ some: 'permission-criteria' }]
// some logic to authorize the user
const requiredPermissions = new Set<string>();
if (Array.isArray(authorizer)) {
authorizer.forEach((a) => requiredPermissions.add(a));
} else {
requiredPermissions.add(authorizer);
}
const hasPermissions = user.permissions.some((p) =>
requiredPermissions.has(p),
);
if (!hasPermissions) {
return res.status(401).json({
message: 'unauthorized',
});
}
return next();
};
const app = new ExpressApp(expressiveRouter, {
authObjectHandler,
});
If the authorizer object is declared at any level (app, subroute, or route), the authorizer object (or string) will be appended to a flattened array in req.authorizer
.
So by declaring the authObjectHandler
in a generic way, we can centrally manage authorization for the whole application at any level by simply defining the authorization criteria in the authorizer
object.
Firstly, Expressive auto generates Swagger documentation from the declared routes, even if you do not provide any documentation of your own. Currently:
- For any project, if you declare basic routes, then the swagger will at least show you the routes that are available on the server.
- If you declare a request validation schema with celebrate syntax, then Swagger parameter definitions will also be auto-generated.
Swagger UI is accessible on the url /docs
, and it requires basic authentication. The default value for both user and password is admin
.
To set your own username and password, set the environment variables EXPRESS_SWAGGER_USER
and EXPRESS_SWAGGER_PASSWORD
respectively.
There are a few things you can configure for Swagger through the ExpressApp options parameter.
You can initialize your app with the basic swagger 'info' property as shown below:
const { ExpressApp } = require('@siddiqus/expressive');
const swaggerInfo = {
version: '1.0.0',
title: 'Example Expressive App',
contact: {
name: 'Sabbir Siddiqui',
email: '[email protected]'
}
};
const app = new ExpressApp(router, {
swaggerInfo: swaggerInfo
});
You can configure whether to only show Swagger UI in development mode, or otherwise
const app = new ExpressApp(router, {
showSwaggerOnlyInDev: false // default value is true, set to false to enable swagger for non-development environments
});
You can add API authentication using OpenAPI 2.0 specification. For example:
const app = new ExpressApp(router, {
swaggerSecurityDefinitions: {
authHeader: {
type: 'apiKey',
name: 'Authorization',
in: 'header'
}
}
});
This will add Authorize option in Swagger UI to add an Authorization
header value.
Each API endpoint can be documented using Swagger syntax, simply by adding a 'doc' class property to the controller class. Example:
import getHelloDoc from './getHelloDoc'; // Swagger doc format for an endpoint, js or json
class GetHelloController extends BaseController {
doc = getHelloDoc // Swagger doc format for an endpoint
// assume handleRequest is implemented here
}
const getUserById = Route.get('/hello', new GetHelloController());
The 'getHelloDoc' JS or JSON could be an Open API 2.x specification JSON, for example:
{
"tags": [
"Hello"
],
"description": "Say hello.",
"responses": {
"200": {
"description": "Say hello.",
"schema": {
"type": "object",
"properties": {
"hello": {
"type": "string",
"example": "world"
}
}
}
}
}
}
To create a swagger.json file, the function writeSwaggerJson
can be used from the SwaggerUtils
export. Example:
const { SwaggerUtils } = require('expressive');
...
SwaggerUtils.writeSwaggerJson({
router, // expressive router configuration
output, // absolute path of output file
basePath, // api base path
swaggerInfo, // basic Swagger info
swaggerSecurityDefinitions // security definitions as per OpenAPI 2 specification
});
The recommended middleware for file uploads is multer. Currently Typescript support is available for req.file
and req.files
but you can declare your own req
type if you need.
Here's an example using multer
, with schema validation:
import { Route, Joi } from '@siddiqus/expressive';
import multer from 'multer';
const uploadFile = multer();
class FileUploadController extends BaseController {
validationSchema = {
fileUpload: {
file: Joi.any().required() // use Joi.any() for any validations
}
}
middleware = [uploadFile.single('file')], // adding multer middleware
handleRequest (req, res) {
const file = req.file;
const filename = file.originalname;
const content = file.buffer.toString();
this.ok({
filename,
content
})
}
};
const fileUploadRoute = Route.put('/file-upload', new FileUploadController());
This provides two benefits:
- Celebrate error handling with validate the form data according to the schema
- Generates swagger doc for file upload
*** Current limitation is the fileUpload
in validationSchema
is limited to file
and files
, which means the formData
property has to be either file
or files
for this to work. You can use multer
's other methods like fields
, but in that case, you won't be able to name the field anything other than file
or files
.
- See the 'example' folder in this repo for the Typescript sample project structure with example controllers and configs.