Allows us to write express routers in typescript way (class-based, typesafe and asyncronous). It's a pluggable library so it's totally compatible with legacy codes, not enforcing application to use its styles.
This simple:
class UserRouter extends ExpressRouter {
@GET({path: '/'})
async getUsers() {
return await UserModel.find().toArray()
}
@POST({path: '/'})
async addUsers(@Body() user: IUser) {
if (!isValidEmail(user.email)) throw new AppLogicError('Invalid email format', 400)
const dupUser = await UserModel.findOne({email: user.email});
if (dupUser) throw new AppLogicError('Email registered', 400)
const result = await UserModel.insertOne(user)
if (!result.ok) throw new Exception(500, 'Cannot insert user')
user._id = result.insertedId;
return user;
}
}
Instead of that verbose:
const router = express.Router();
router.get('/', (req, resp) => {
return UserModel.find().toArray()
.then(users => resp.json(users))
.catch(err => {resp.status(500); resp.json({error: err.message})})
})
router.post('/', (req, resp) => {
if (!isValidEmail(req.body.email)) {
resp.status(400)
resp.send({error: 'Invalid email format'})
return
}
return UserModel.findOne({email: req.body.email})
.then(dupUser => {
if (dupUser) {
resp.status(400);
return resp.send({error: 'Email registered'})
}
})
}
return UserModel.insertOne(user).then(result => {
if (!result.ok) {
resp.status(500);
return resp.send({error: 'Cannot insert user'})
}
else {
user._id = result.insertedId
return resp.send(user)
}
})
})
})
export default router;
- This is for typescript only. So we need typescript
- reflect-metadata must be turned on (
tsconfig.json
)
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
- Install via
npm
or yarn:
npm install --save express-router-ts
Pretty straight forward, first having routing class:
import { ExpressRouter, POST, Body} from 'express-router-ts'
export class AuthRouter extends ExpressRouter {
@POST({path: '/login'})
async login(@Body('username') username: string, @Body('password') password: string) {
const user = await UserModel.findOne({username});
if (!user) throw new Error('User not found');
if (user.passwordSHA != sha(password, user.salt)) throw new Error('Cannot login')
return await jwt.generateToken(user._id)
}
@POST({path: '/register'})
async register(@Body('username') username: string, @Body('password') password: string) {
const dupUser = await UserModel.findOne({username});
if (dupUser) throw new Error('User registered');
const salt = randomstring.generate({length: 16})
const result = await UserModel.insertOne({
username,
passwordSHA: sha(password, salt),
salt
})
if (!result.ok) throw new Error('Cannot insert user')
return {_id: result.insertedId}
}
}
Then loading it into your express
server using its .Router
property:
const server = express();
server.use('/auth', new AuthRouter().Router)
ExpressRouter
is the base of every router class. Each of them is equivalent (and will be mapped) to an express.Router object. An ExpressRouter
object has only 2 properties:
Router
: returns an equivalentexpress.Router
objectPath
: The route path of the router (only used if it's loaded via loadDir method)
Beside, the ExpressRouter
class itself has some useful static methods / properties:
ExpressRouter.loadDir
: loading a directory of router files instead of each object. Check loadDirExpressRouter.NEXT
: A symbol used to triggernext()
function. Check MiddlewaresExpressRouter.ResponseHandler
: Check Response handlersExpressRouter.ErrorHandler
: Check Response handlers
Similarly, a route method is a method inside a router class marked with a correspond HTTP method decorator. It's equivalent to a express route handler function although it must be async:
class UserRouter extends ExpressRouter {
@GET({path: '/:id'})
async getUser(@Params('id') id: string) {
return await UserModel.findOne({_id: new ObjectId(id)
return resp.json({
_id: result.insertedId,
...req.body
});
}
@PUT({path: '/:id'})
@ValidBody({
'+@name': 'string',
'+@email': 'string',
'++': false
})
async updateUser(@Params('id') id: string, @Body() updateBody: any) {
const result = await UserModel.updateOne({_id: new ObjectId(id)}, {$set: updateBody});
return result.ok == true
}
}
There are some kind of decorators inside a route method definition:
- HTTP method decorator: (
@GET
,@POST
,@PUT
,....) Defined decorators imported fromexpress-router-ts
lib. It may havepath
option:path
: The route path of the method. Default is the method name.
- Middleware decorators: The @ValidBody decorator in the example above
- Argument decorators: How to retrieve values for the method arguments
- Response decorators: In case we need different response handler instead of default. Check Response a file code samples.
- The method body: Your logic
Middlewares is represented as a method decorator. Use addMiddlewareDecor
function to create a middleware, example:
export function ApiLog() {
return addMiddlewareDecor(async req => {
console.log(`${req.method} ${req.url}; Time: ${Date.now()}`)
throw ExpressRouter.NEXT
})
}
class UserRouter extends ExpressRouter {
@ApiLog() // <- it's used here
@GET({path: '/:id'})
async getUser(@Params('id') id: string) {
return await UserModel.findOne({_id: new ObjectId(id)});
}
}
Note: In order to forward request to the next handler, we throw ExpressRouter.NEXT
symbol. It's equivalent to call next()
function in traditional expressjs
.
Decorators indicate how to retrieve values for route method arguments. We supports all default mappers to get data from request object:
@Query(arg?: string | Function)
: corresponding field name in request's query or a function to extract values from query. Default are empty string which will returns all the query object.@Body(arg?: string | Function)
: Same as above, but for body object.@Query(arg?: string | Function)
: Same as above, for url params.@Req(arg?: string | Function)
: Same as above, but work for entire request object.
We can create new mappers as well, use argMapperDecor
function:
// Assume that we're using express-session
// https://www.npmjs.com/package/express-session
export function SessionId() {
return argMapperDecor(async req => {
return req.session.id;
})
}
export const Nonce = argMapperDecor(async req => {
return uuid.v4()
}
class ExampleRouter extends ExpressRouter {
const count = {}
@GET({path: '/count'})
async countReq(@SessionId() sid: string, @Nonce nonce: string) {
if (!this.count[sid]) this.count[sid] = 0
this.count[sid] += 1
return {
nonce,
counter: this.count[sid]
}
}
}
Response handlers is used to format the returned data (from route methods) before responding to client. There're 2 type of response handlers:
ResponseHandler
: success responseErrorHandler
: used if having exception
Default, all route methods using global response handlers, declared as static fields in ExpressRouter
class (ExpressRouter.ResponseHandler
and ExpressRouter.ErrorHandler
) that does nothing but response data directly to client (and set status code to 500 if error).
I usually apply my own response handlers:
export class AppApiResponse {
constructor(success: boolean = true) {
this.success = success;
}
success: boolean;
httpCode?: number;
headers?: {[header: string]: string} = {}
err?: IAppErrorResponse;
data?: any;
meta?: any;
}
ExpressRouter.ResponseHandler = (data, req, resp) => {
let appResp = new AppApiResponse();
if (data instanceof AppApiResponse) {
appResp = data;
}
else {
appResp.success = true;
appResp.httpCode = 200;
appResp.data = data;
}
response(appResp, resp)
}
ExpressRouter.ErrorHandler= (err, req, resp) => {
let appResp = new AppApiResponse();
appResp.success = false;
appResp.err = {
message: err.message || 'Unknown error',
code: err.code,
params: err.params
}
appResp.httpCode = _.isNumber(err.httpCode) ? err.httpCode : 500;
response(appResp, resp)
}
function response(appResp, resp) {
if (_.isNumber(appResp.httpCode)) {
resp.statusCode = appResp.httpCode;
}
delete appResp.httpCode;
// Remove headers from response body
if (!_.isEmpty(appResp.headers)) {
_.keys(appResp.headers).forEach(h => resp.setHeader(h, appResp.headers[h]));
}
delete appResp.headers;
resp.send(appResp);
}
Example responses:
@GET()
async routeMethodSuccess() {
return {
"name": "Mike",
"age": 19
}
}
// Success: Status 200
{
"success": true,
"data": {
"name": "Mike",
"age": 19
}
}
@GET()
async routeMethodFailed() {
throw new AppError("User not found", 400)
}
// Error: Status 400
{
"success": false,
"err": {
"message": "User not found"
}
}
We usually store all routers inside one directory. So instead of loading each of them explicitly to the app, why don't load all directly, using ExpressRouter.loadDir
method:
Some note for using this function:
- To be imported, the router file must export an object of that router as default export.
class ExampleRouter extends ExpressRouter {
@GET({path: '/'})
async api() {
return 'Hello'
}
}
export default new ExampleRouter(); // <-- Important! must export default here
- The route path of the router is the filename as default, unless the
.Path
property of the router is set
// filename: example.ts
class ExampleRouter extends ExpressRouter {
}
export default new ExampleRouter();
// This router will be routed under '/example' path as default
// filename: custompath.ts
class CustomPathRouter extends ExpressRouter {
get Path() {
return '/users'
}
}
export default new CustomPathRouter();
// This router will be routed under '/users' path as defined in `Path` property
- While this lib is for
typescript
, the actual loaded files are usually.js
files, so please take attention into your builder configuration andcwd
to ensure the loading path is set correctly.
A typical problem when writing REST is ensure the body format is valid, let's write a middleware decorator for that. In this example I use ajv2 to validate json:
const ajv = newAjv2();
export function ValidBody(schema: object) {
const validator = ajv(schema);
return addMiddlewareDecor(async req => {
if (!validator(req.body)) throw new AppLogicError('Invalid request body!', 400, validator.errors);
})
}
class UserRouter extends ExpressRouter {
@PUT({path: '/:id'})
@ValidBody({
'+@name': 'string',
'+@email': 'string',
'++': false
})
async updateUser(@Params('id') id: string, @Body() updateBody: any) {
const result = await UserModel.updateOne({_id: new ObjectId(id)}, {$set: updateBody});
return result.ok == true
}
}
Another common problem is authorization. Ensure an API is called by user with proper permission only. Let's have a simple role-based access control middleware with jwt for tokens:
export function AuthRole(...roles: string[]) {
return addMiddlewareDecor(async (req: express.Request) => {
if (!req.session.user) {
const accessToken = req.header('authorization');
if (!accessToken) throw new AppLogicError(`Unauthorized! ${err}`, 401);
try {
data = jwt.verify(accessToken, 'secrect');
req.session.user = data && await UserModel.find({_id: new ObjectId(data.id)})
if (!req.session.user) throw new Error()
}
catch (err) {
throw new AppLogicError(`Unauthorized! ${err}`, 401);
}
}
if (roles.length > 0) {
const user = req.session.user;
if (!roles.find(r => user.roles.includes(r))) new AppLogicError(`Unauthorized! ${err}`, 401);
}
});
}
@GET({path: '/me'})
@AuthRole() // <- Every authenticated user can get their profile
async getProfile(@Req('session.user') user: IUser) {
return user;
}
@PUT({path: '/:id'})
@ValidBody({
'+@name': 'string',
'+@email': 'string',
'++': false
})
@AuthRole('ADMIN') // <- Only user with admin role can access to this API
async updateUser(@Params('id') id: string, @Body() updateBody: any) {
const result = await UserModel.updateOne({_id: new ObjectId(id)}, {$set: updateBody});
return result.ok == true
}
}
In all examples above, we just use the default ResponseHandler
which formated respons data as json
only. What if we want to response data in other formats ?
In this example we will write an API that response a CSV
file:
export function CSVResponse(fnFilename: (data: any, req: express.Request) => string, csvOptions: object) {
return ResponseHandler(async (data, req, resp) => {
const csv = toCSV(data, csvOptions) // toCSV function converts from json to csv, replace with your own logic
const filename = fnFilename(data, req)
resp.set({"Content-Disposition":`attachment; filename="${filename}"`})
resp.send(csv)
})
}
The API below will response a list of all users as a CSV file. While this decorator applied for this API only, all other APIs still responses data in json format (as default).
class UserRouter extends ExpressRouter {
@GET({path: '/'})
@AuthRole('ADMIN')
@CSVResponse(() => 'users.csv')
async getAllUsers() {
return await User.findAll();
}
}
If you have found a bug or have a feature request, feel free to report them at this repository issues section.
You are welcome
Written with StackEdit.