@express-plus/core

0.7.1 • Public • Published

Features

  • Create robust express REST APIs in seconds
  • Extensible and lightwieight with minimal additional dependencies
  • Run-time type checking and schema validation
  • Simplified controller <--> endpoint descriptions

Express+

Table of Contents

Simplified Endpoints

In the following example, we bind a GET and POST Method to the following routes

  • GET: {host}/helloworld/test
  • POST: {host}/helloworld/testSchema

hello-world-controller.ts:

@Controller()
export class HelloWorldController extends ApiController {

  constructor() {
    super();
  }

  @HttpGet()
  public async test({ req, res }: HttpContext) {
    return Ok(res, 'Hello World!');
  }

  @HttpPost({ fromBody: ExampleObject })
  public async testSchema({ req, res }: HttpContext) {
    return Ok(res, 'Post Received');
  }
}

main.ts

const server = new Server(
  environment,
  {
    controllers: [
      HelloWorldController,
    ],
  });

server.start();
// Log: endpoint added at: /helloworld/test
// Log: endpoint added at: /helloworld/testSchema

Input Validation

Explicit Input Schema Validation

Express+ allows for run-time schema validaton on HTTP POST and PUT requests with the fromBody optional parameter in the @HttpPost and @HttpPut decorators

hello-world-controller.ts

  @HttpPost({ fromBody: ExampleObject })
  public async testSchema({ req, res }: HttpContext) {
    return Ok(res, 'Post Received');
  }

example-object.ts

export interface IExampleObject {
  id?: number;
  value?: string;
  phone?: string;
}

export class ExampleObject implements IExampleObject {

  @IsRequired()
  @IsInt()
  public id: number;

  @IsRequired()
  @Length(0, 5)
  public value: string;

  @IsOptional()
  @Matches(/^[(][0-9]{3}[)][0-9]{3}[-][0-9]{4}$/) // ex: (800)123-4567
  public phone: string;

}
Input Result
{"id": 1} 'value' missing
{"id": 1, "value": "Hello World"} 'value' length must be less than or equal to 5
{"id": 1, "value": "Test", "phone": "8001234567"} 'phone' format does not match
{"id": 1, "value": "Test"} Ok
{"id": 1, "value": "Test", "phone": "(800)123-4567"} Ok

Routing

Implicit Route Handling

By default, routes are handled implcitly. Assume our default route is {host}/.

The first route parameter is handled by the name of the controller. With the following code in hello-world-controller.ts we infer all the endpoints within this controller to be prefixed with helloworld because of the naming convention of the controller combined with the decorator @Controller

@Controller()
export class HelloWorldController extends ApiController {

  constructor() {
    super();
  }
}

At this point. We have only declared the default route for our HelloWorldController at {host}/HelloWorld/

To declare an endpoint, we simply add a method in our Controller with any of the @Http{type} decorators such as @HttpGet or @HttpPost

  @HttpGet()
  public async test({ req, res }: HttpContext) {
    return Ok(res, 'Hello World!');
  }

  @HttpPost()
  public async testSchema({ req, res }: HttpContext) {
    return Ok(res, 'Ok');
  }

With these endpoints delcared inside our HelloWorldController, we have the following routes:

  • GET: {host}/helloworld/test
  • POST: {host}/helloworld/testSchema

Explicit Route Handling

Assuming we utilize the same controller and endpoint methods as described above. We are provided the following ways to modify our routing of our Endpoints.

Server Level Route Prefixing

In our main.ts file we can use the routePrefix option in the Server constructor to specify all routes be prefixed with our parameter:

const server = new Server(
  environment,
  {
    controllers: [
      HelloWorldController,
    ],
    routePrefix: 'api'
  });

Given all else equal, our routes will now look like:

  • GET: {host}/api/helloworld/test
  • POST: {host}/api/helloworld/testSchema

Controller Level Routing

We can also optionally specify routes at a controller level by specifying a route in the @Controller decorator. The preserveCase parameter, allows for the route case to be preserved as-is. Otherwise, the controller route is converted to lowercase.

@Controller({route: 'v1/MyController', preserveCase: true })
export class HelloWorldController extends ApiController {

  constructor() {
    super();
  }
}

Given all else equal, our routes will now look like

  • GET: {host}/v1/MyController/test
  • POST: {host}/v1/MyController/testSchema

Endpoint Level Routing

We can specify routes at the endpoint level as well. Currently this is also the only way to pass route parameter expextations as well.

  @HttpGet()
  public async test({ req, res }: HttpContext) {
    return Ok(res, 'Ok');
  }

  @HttpPost({ route: 'CustomPostRoute' })
  public async testSchema({ req, res }: HttpContext) {
    return Ok(res, 'Ok');
  }

Given all else equal using our HelloWorldController, our routes will now look like

  • GET: {host}/helloworld/test
  • POST: {host}/helloworld/CustomPostRoute

Specifying Route Parameters

To add route parameters to an endpoint, simply supply them via the params option in your endpoint decorator.

  @HttpGet({ params: ":id/:value?" })
  public async GetWithParams({ req, res }: HttpContext) {
    return Ok(res, req.params);
  }

This will register the route: GetWithParams/:id/:value? to our controller, requiring the id parameter and value being optional. A good practice is to pass the ParamType to the HttpContext and get full type-hinting of your req.params.

Accepting Multiple HTTP Methods

Good REST API design practices call for dictating CRUD (create, read, update delete) operations to be dictated on each route.

In Express+ we can easily handle overloading the same route endpoint to accept multiple HTTP methods.

Route Method Action
/products GET Returns list of Products
/products/:id GET Returns specific Product
/products POST Creates a new Product
/products PUT Updates a Product
/products/:id DELETE Deletes a specific Product
@Controller()
export class ProductsController extends ApiController {

  constructor() {
    super();
  }

  @HttpGet({ params: ':id?' })
  @HttpDelete({ params: ':id' })
  @HttpPost({ fromBody: Product })
  @HttpPut({ fromBody: Product })
  public async default({ req, res }: HttpContext<Product>) {
    switch (req.method) {
      case 'GET': {
        return Ok(res, await this.getProducts(req.params.id));
      }
      case 'POST': {
        return Ok(res, await this.createProduct(req.body, this.products));
      }
      case 'PUT': {
        return Ok(res, await this.updateProduct(req.body, this.products));
      }
      case 'DELETE': {
        return Ok(res, await this.deleteProduct(req.params.id, this.products));
      }
    }
  }
}

In the above example, we overload the default route which is mapped to the route specified by the Controller. Each decorator is handled just as it would if it was dictated in a separate function. For example, only the POST and PUT methods will validate the body, and only the GET and DELETE methods will accept an id in the URL.

Middleware

One of the goals of Express+ is to cut down on the verbosity of the middleware stack for endpoints. Here are some examples of built-in middleware functions.

Content-Types

On HTTP POST and PUT requests, you might want to only accept a certain content type, and it may differ from the content types accepted by other endpoint methods.

  @HttpPut({ contentType: HttpContentType.UrlEncoded })
  public async TestPut({ req, res }: HttpContext) {
    console.dir(req);
    return Ok(res, 'Put Successful');
  }

Depending on the content type you declare JSON (default), UrlEncoded, FormData, etc.., middleware will be applied to this route to ensure the incoming request conforms to that content-type before it ever hits the endpoint method.

Authentication

Express+ simplifies authentication with the authenticate parameter passed into any @Http{Type} decorator.

  @HttpGet({ authenticate: true })
  public async TestAuth({ req, res }: HttpContext) {
    return Ok(res, res.locals);
  }

This method will use the default authenticator provided in your Server

A boilerplate authenticator may resemble the following:

test-auth.ts:

export function TestAuth(req: Request<Dictionary<string>>, res: Response, next: NextFunction): Promise<void> {
  return new Promise((resolve, reject) => {
    if (req.headers.sessiontoken === '123456') { // validate the user against your db
      res.locals.user = { userId: 1 }; // pass on the user info
      resolve();
      next();
    } else {
      throw new UnauthorizedError();
    }
  });
}

Which can be implemented into the server via the constructor.

main.ts:

const server = new Server(
  environment,
  {
    authMethod: TestAuth,
    controllers: [
      HelloWorldController,
    ]
  });

server.start();

Endpoint specified auth methods

If you want to use a separate authentication method for varying endpoints, the method can be specified in the endpoint options such as:

  @HttpGet({ authenticate: true, authMethod: TestAuth })
  public async TestAuth({ req, res }: HttpContext) {
    return Ok(res, res.locals);
  }

Error Handling

Natively, express handles errors quite well. However, to give more control and precision over errors thrown in promises or asyncronous operations, you have the ability to create default error handlers.

By default, Express+ handles errors in a simplified fashion, any throw new Error() or code with Express+ Error classes extended from ApplicationError will interrupt the endpoint method call-stack and returns a JSON formatted response such as:

{
    "name": "UnauthorizedError",
    "message": "Insufficient Permission",
    "status": 401
}

Express+ provides Error classes for the most frequently used Error response codes, which will ensure the response is sent with the proper HTTP Code and a default (but optionally specified) message

Error Code Message
ApplicationError (Default) 500 Something went wrong, please try again
BadRequestError 400 Bad request from client
ConflictError 403 The request could not be completed due to a conflict with the current state of the resource
ForbiddenError 403 Access forbidden
NotFoundError 404 No matching resource found
UnauthorizedError 401 Insufficient permission

The following usage in your code:

 throw new BadRequestError('Expected array of parameters');

Will return to client:

{
    "name": "BadRequestError",
    "message": "Expected array of parameters",
    "status": 403
}

Overriding default error handling

You can provide any error handler complying to express.js docs to the Server constructor such as:

const server = new Server(
  environment,
  {
    controllers: [
      HelloWorldController
    ],
    errorHandler: (err, req, res, next) => {
      if (res.headersSent) {
        return;
      }
      return res.status(500).send({name: 'CustomError', message: err.message});
    }
  });

server.start();

You can also provide** Endpoint Specific** error handlers via:

hello-world-controller.ts

  @HttpGet({ errorHandler: MyHandler})
  public async test({ req, res }: HttpContext) {
    return Ok(res, req.params);
  }

my-handler.ts

export function MyHandler(err, req, res, next){
  // your code
}

CORS

(Needs testing)

Express+ uses the cors middleware package as documented in the express docs.

To implement server-wide CORS, specify the CorsOptions in the Server constructor. For example:

main.ts

const server = new Server(
  environment,
  {
    controllers: [
      HelloWorldController
    ],
    cors: {
      origin: ['https://www.mysite.com']
    }
  });

server.start();

You can also optionally specify CORS policies on individual routes by specifying the cors parameter in the Http{Type} endpoint decorator.

hello-world-controller.ts

  @HttpGet({ cors: { origin: 'https://test.com' } })
  public async TestCors({ req, res }: HttpContext) {
    return Ok(res, 'CORS is good');
  }

You can optionally pass false to this endpoint parameter option if you want to disable all CORS policies for this endpoint.

Endpoint Arguments and HttpContext

All Express+ Endpoints are expected to allow the same argument of HttpContext. This is a basic wrapper class that contains the Request and Response of the incoming http request.

  @HttpGet({ route: 'Test', params: ':id' })
  public async Test({ req, res }: HttpContext<any, { id: string }>) {
    if(req.params.id === '1'){
      return Ok(res, 'Received One');
    } else if(req.params.id === '2'){
      return Ok(res, 'Received Two');
    } else {
      return Ok(res, 'Received other');
    }
  }

Q: Why is there no next arugment in HttpContext

Your Endpoint method should be the last method in the stack at the provided route. This is one of the few opinionated assumptions made by Express+.

Request Type Hinting

The HttpContext class contains optional Type parameters to supply to get compile-time type-hinting: HttpContext<BodyType, ParamsType, QueryType>

These types are applied to the req.body req.params and req.query respectively.

Get body type

  @HttpPost({ fromBody: ExampleObject })
  public async PostWithSchemaValidation({ req, res }: HttpContext<ExampleObject>) {
    return Ok(res, 'formatting good');
  }

In this example we pass the ExampleObject used earlier to ensure type-hinting and intellisense when accessing the req.body from this point forward. In this case we can trust the data because ExampleObject is being validated via the fromBody option passed into the @HttpPost decorator.

Get URL Params:

  @HttpGet({ params: ":id/:value?" })
  public async GetWithParams({ req, res }: HttpContext<any, { id: string; value?: string }>) {
    return Ok(res, req.params);
  }

When we pass { id: string; value?: string } into the ParamsType of the HttpContext our req.params type hinting works as expected.

Get URL query string:

  @HttpGet()
  public async GetWithQuery({ req, res }: HttpContext<any, any, Partial<{ id: string, value: string }>>) {
    return Ok(res, req.query);
  }

In this example, Partial<{ id: string, value: string }> is supplied to the QueryType of our HttpContext. This allows our req.query parameter to have full type hinting and intellisense. We use the Partial<T> utility type because our query strings should be treated as untrusted.

Return Methods

Express+ provdes return type wrapper methods to automatically handle the response of your Endpoints. It should be noted these are entirely optional.

hello-world-controller.ts

  @HttpGet()
  public async Test({ req, res }: HttpContext) {
    return Ok(res, 'Hello World');
  }

The Ok method will set the status of the response to 200 and return whatever is provided for the body.

Express+ return methods include:

Method Code Allow Body
Ok 200 Yes
Created 201 Yes
NoContent 204 No
NotModified 304 Yes

Returning bad requests

Return bad requests by throwing one of the provided Error objects. These types throw a status code that corresponds to the HTTP status code name, and format the response accordingly via the default error handler.

@HttpGet()
public async neverAccess({ req, res }: HttpContext) {
   throw new ForbiddenError('This route should never be accessed');
}

Will return the following HTTP response unless the default error handler is overwritten:

status: 403
body: 
{
	"name": "ForbiddenError",
	"message": "This should never be accessed",
	"status": 403
}

Environments

Express+ offers simple solutions to handling environment variables.

A ServerEnvironment instance must be passed to the Server constructor. However, there are multiple ways to initiate these variables.

Explicit declaration:

const environment = new ServerEnvironment({
  port: '8000',
  debug: true
});

Node.JS environment variables:

const environment = new ServerEnvironment();

Environment variables to be set:

Variable Value Default
PORT Any numeric value 80
NODE_ENV development or production production

You then pass this object into the Server constructor

const environment = new ServerEnvironment();

const server = new Server(
  environment,
  {
    controllers: [
      HelloWorldController
    ]
  });

server.start();

Readme

Keywords

none

Package Sidebar

Install

npm i @express-plus/core

Weekly Downloads

1

Version

0.7.1

License

ISC

Unpacked Size

121 kB

Total Files

106

Last publish

Collaborators

  • reed-lawrence