@travetto/rest

Declarative api for RESTful APIs with support for the dependency injection module.

Usage no npm install needed!

<script type="module">
  import travettoRest from 'https://cdn.skypack.dev/@travetto/rest';
</script>

README

RESTful API

Declarative api for RESTful APIs with support for the dependency injection module.

Install: @travetto/rest

npm install @travetto/rest

The module provides a declarative API for creating and describing an RESTful application. Since the framework is declarative, decorators are used to configure almost everything. The module is framework agnostic (but resembles express in the TravettoRequest and TravettoResponse objects). This module is built upon the Schema structure, and all controller method parameters follow the same rules/abilities as any @Field in a standard @Schema class.

Routes: Controller

To define a route, you must first declare a @Controller which is only allowed on classes. Controllers can be configured with:

  • title - The definition of the controller
  • description - High level description fo the controller

Additionally, the module is predicated upon Dependency Injection, and so all standard injection techniques (constructor, fields) work for registering dependencies.

JSDoc comments can also be used to define the title attribute.

Code: Basic Controller Registration

import { Controller } from '@travetto/rest';

@Controller('/simple')
class SimpleController {
  // routes
}

Routes: Endpoints

Once the controller is declared, each method of the controller is a candidate for routing. By design, everything is asynchronous, and so async/await is natively supported.

The HTTP methods that are supported via:

Each endpoint decorator handles the following config:

  • title - The definition of the endpoint
  • description - High level description fo the endpoint
  • responseType? - Class describing the response type
  • requestType? - Class describing the request body

JSDoc comments can also be used to define the title attribute, as well as describing the parameters using @param tags in the comment.

Additionally, the return type of the method will also be used to describe the responseType if not specified manually.

Code: Controller with Sample Route

import { Get, Controller } from '@travetto/rest';

class Data { }

@Controller('/simple')
class SimpleController {

  /**
   * Gets the most basic of data
   */
  @Get('/')
  async simpleGet() {
    let data: Data | undefined;
    //
    return data;
  }
}

Note: In development mode the module supports hot reloading of classes. Routes can be added/modified/removed at runtime.

Parameters

Endpoints can be configured to describe and enforce parameter behavior. Request parameters can be defined in five areas:

Each @Param can be configured to indicate:

  • name - Name of param, field name, defaults to handler parameter name if necessary
  • description - Description of param, pulled from JSDoc, or defaults to name if empty
  • required? - Is the field required?, defaults to whether or not the parameter itself is optional
  • type - The class of the type to be enforced, pulled from parameter type

JSDoc comments can also be used to describe parameters using @param tags in the comment.

Code: Full-fledged Controller with Routes

import { Get, Controller, Post, Query, Request } from '@travetto/rest';
import { Integer, Min } from '@travetto/schema';

import { MockService } from './mock';

@Controller('/simple')
export class Simple {

  constructor(private service: MockService) { }

  /**
   * Get a random user by name
   */
  @Get('/name')
  async getName() {
    const user = await this.service.fetch();
    return `/simple/name => ${user.first.toLowerCase()}`;
  }

  /**
   * Get a user by id
   */
  @Get('/:id')
  async getById(id: number) {
    const user = await this.service.fetch(id);
    return `/simple/id => ${user.first.toLowerCase()}`;
  }

  @Post('/name')
  async createName(person: { name: string }) {
    await this.service.update({ name: person.name });
    return { success: true };
  }

  @Get(/\/img(.*)[.](jpg|png|gif)/)
  async getImage(
    req: Request,
    @Query('w') @Integer() @Min(100) width?: number,
    @Query('h') @Integer() @Min(100) height?: number
  ) {
    const img = await this.service.fetchImage(req.path, { width, height });
    return img;
  }
}

Body and QuerySchema

The module provides high level access for Schema support, via decorators, for validating and typing request bodies.

@Body provides the ability to convert the inbound request body into a schema bound object, and provide validation before the controller even receives the request.

Code: Using Body for POST requests

import { Schema } from '@travetto/schema';
import { Controller, Post, Body } from '@travetto/rest';

@Schema()
class User {
  name: string;
  age: number;
}

@Controller('/user')
class UserController {

  private service: {
    update(user: User): Promise<User>;
  };

  @Post('/saveUser')
  async save(@Body() user: User) {
    user = await this.service.update(user);
    return { success: true };
  }
}

@QuerySchema provides the ability to convert the inbound request query into a schema bound object, and provide validation before the controller even receives the request.

Code: Using QuerySchema for GET requests

import { Schema } from '@travetto/schema';
import { Controller, Get, QuerySchema } from '@travetto/rest';

@Schema()
class SearchParams {
  page: number = 0;
  pageSize: number = 100;
}

@Controller('/user')
class UserController {

  private service: {
    search(query: SearchParams): Promise<number[]>;
  };

  @Get('/search')
  async search(@QuerySchema() query: SearchParams) {
    return await this.service.search(query);
  }
}

Addtionally, @QuerySchema and @Body can also be used with interfaces and type literals in lieu of classes. This is best suited for simple types:

Code: Using QuerySchema with a type literal

import { Controller, Get, QuerySchema } from '@travetto/rest';

type Paging = {
  page?: number;
  pageSize?: number;
};

@Controller('/user')
class UserController {

  private service: {
    search(query: Paging): Promise<number>;
  };

  @Get('/search')
  async search(@QuerySchema() query: Paging = { page: 0, pageSize: 100 }) {
    return await this.service.search(query);
  }
}

Input/Output

The module provides standard structure for rendering content on the response. This includes:

  • JSON
  • String responses
  • Files

Per the Base module, the following types automatically have rest support as well:

  • Map - Serializes as a JSON object
  • Set - Serializes as an array
  • Error - Serializes to a standard object, with status, and the error message.
  • AppError - Serializes like Error but translates the error category to an HTTP status

Additionally, the Schema module supports typing requests and request bodies for run-time validation of requests.

Running an App

By default, the framework provices a default @Application at RestApplication that will follow default behaviors, and spin up the REST server. You will need to install the Application module to execute.

Install: Installing app support

npm install @travetto/app

Terminal: Standard application

$ trv run

Usage: trv run [options] [application] [args...]

Options:
  -e, --env <env>            Application environment
  -p, --profile <profile>    Additional application profiles (default: [])
  -r, --resource <resource>  Additional resource locations (default: [])
  -h, --help                 display help for command

Available Applications:

   ● rest Default rest application entrypoint
     ----------------------------------------
     usage: rest 
     file:  src/application/rest.ts

Creating a Custom App

To customize a REST server, you may need to construct an entry point using the @Application decorator. This could look like:

Code: Application entry point for Rest Applications

import { Application } from '@travetto/app';
import { RestApplication } from '@travetto/rest';

@Application('custom')
export class SampleApp extends RestApplication {

  override run() {
    // Configure server before running
    return super.run();
  }
}

And using the pattern established in the Application module, you would run your program using npx trv run custom.

Terminal: Custom application

$ trv run

Usage: trv run [options] [application] [args...]

Options:
  -e, --env <env>            Application environment
  -p, --profile <profile>    Additional application profiles (default: [])
  -r, --resource <resource>  Additional resource locations (default: [])
  -h, --help                 display help for command

Available Applications:

   ● custom 
     ------------------------
     usage: custom 
     file:  doc/custom-app.ts

   ● rest Default rest application entrypoint
     ----------------------------------------
     usage: rest 
     file:  src/application/rest.ts

Interceptors

RestInterceptors are a key part of the rest framework, to allow for conditional functions to be added, sometimes to every route, and other times to a select few. Express/Koa/Fastify are all built around the concept of middleware, and interceptors are a way of representing that.

Code: A Trivial Intereptor

import { RestInterceptor, SerializeInterceptor, Request, Response } from '@travetto/rest';
import { Injectable } from '@travetto/di';

@Injectable()
export class HelloWorldInterceptor implements RestInterceptor {

  after = [SerializeInterceptor];

  intercept(req: Request, res: Response) {
    console.log('Hello world!');
  }
}

Note: The example above defines the interceptor to run after another interceptor class. The framework will automatically sort the interceptors by the before/after reuirements to ensure the appropriate order of execution.

Out of the box, the rest framework comes with a few interceptors, and more are contributed by other modules as needed. The default interceptor set is:

  1. SerializeInterceptor - This is what actually sends the response to the requestor. Given the ability to prioritize interceptors, another interceptor can have higher priority and allow for complete customization of response handling.
  2. CorsInterceptor - This interceptor allows cors functionality to be configured out of the box, by setting properties in your application.yml, specifically, rest.cors.active: true

Code: Cors Config

export class RestCorsConfig {
  /**
   * Is cors active
   */
  active: boolean = false;
  /**
   * Allowed origins
   */
  origins?: string[];
  /**
   * Allowed http methods
   */
  methods?: Request['method'][];
  /**
   * Allowed http headers
   */
  headers?: string[];
  /**
   * Support credentials?
   */
  credentials?: boolean;
}
  1. CookiesInterceptor - This interceptor is responsible for processing inbound cookie headers and populating the appropriate data on the request, as well as sending the appropriate response data

Code: Cookies Config

export class RestCookieConfig {
  /**
   * Are cookies supported
   */
  active = true;
  /**
   * Are they signed
   */
  signed = true;
  /**
   * Supported only via http (not in JS)
   */
  httpOnly = true;
  /**
   * Enforce same site policy
   */
  sameSite: cookies.SetOption['sameSite'] | 'lax' = 'lax';
  /**
   * The signing keys
   */
  keys = ['default-insecure'];
  /**
   * Is the cookie only valid for https
   */
  secure?: boolean;
  /**
   * The domain of the cookie
   */
  domain?: string;
}
  1. GetCacheInterceptor - This interceptor, by default, disables caching for all GET requests if the response does not include caching headers. This can be disabled by setting rest.disableGetCache: true in your config.
  2. LoggingInterceptor - This interceptor allows for logging of all requests, and their response codes. You can deny/allow specific routes, by setting config like so

Code: Control Logging

rest.logRoutes.{deny|allow}:
- '/controller1'
- '/controller1:*'
- '/controller2:/path'
- '/controller3:/path/*`

Custom Interceptors

Additionally it is sometimes necessary to register custom interceptors. Interceptors can be registered with the Dependency Injection by extending the RestInterceptor class. The interceptors are tied to the defined TravettoRequest and TravettoResponse objects of the framework, and not the underlying app framework. This allows for Interceptors to be used across multiple frameworks as needed. A simple logging interceptor:

Code: Defining a new Interceptor

import { Request, Response, RestInterceptor } from '@travetto/rest';
import { Injectable } from '@travetto/di';

class Appender {
  write(...args: unknown[]): void { }
}

@Injectable()
export class LoggingInterceptor implements RestInterceptor {

  constructor(private appender: Appender) { }

  async intercept(req: Request, res: Response) {
    // Write request to database
    this.appender.write(req.method, req.path, req.query);
  }
}

A next parameter is also available to allow for controlling the flow of the request, either by stopping the flow of interceptors, or being able to determine when a request starts, and when it is ending.

Code: Defining a fully controlled Interceptor

import { RestInterceptor, Request, Response } from '@travetto/rest';
import { Injectable } from '@travetto/di';

@Injectable()
export class LoggingInterceptor implements RestInterceptor {
  async intercept(req: Request, res: Response, next: () => Promise<unknown>) {
    const start = Date.now();
    try {
      await next();
    } finally {
      console.log('Request complete', { time: Date.now() - start });
    }
  }
}

Currently Asset Rest Support is implemented in this fashion, as well as Rest Auth.

Cookie Support

express/koa/fastify all have their own cookie implementations that are common for each framework but are somewhat incompatible. To that end, cookies are supported for every platform, by using cookies. This functionality is exposed onto the TravettoRequest/TravettoResponse object following the pattern set forth by Koa (this is the library Koa uses). This choice also enables better security support as we are able to rely upon standard behavior when it comes to cookies, and signing.

Code: Sample Cookie Usage

import { GetOption, SetOption } from 'cookies';

import { Controller, Get, Query, Request, Response } from '@travetto/rest';

@Controller('/simple')
export class SimpleRoutes {

  private getOptions: GetOption;
  private setOptions: SetOption;

  @Get('/cookies')
  cookies(req: Request, res: Response, @Query() value: string) {
    req.cookies.get('name', this.getOptions);
    res.cookies.set('name', value, this.setOptions);
  }
}

SSL Support

Additionally the framework supports SSL out of the box, by allowing you to specify your public and private keys for the cert. In dev mode, the framework will also automatically generate a self-signed cert if:

  • SSL support is configured
  • node-forge is installed
  • Not running in prod
  • No keys provided

This is useful for local development where you implicitly trust the cert.

SSL support can be enabled by setting rest.ssl.active: true in your config. The key/cert can be specified as string directly in the config file/environment variables. The key/cert can also be specified as a path to be picked up by the ResourceManager.

Full Config

The entire RestConfig which will show the full set of valid configuration parameters for the rest module.

Serverless

AWS Lambda

The module provides support basic support with AWS lambdas. When using one of the specific rest modules (e.g. Express REST Source), you can install the appropriate lambda-related dependencies installed (e.g. aws-serverless-express) to enable integration with AWS. Nothing in the code needs to be modified to support the AWS integration, but there are some limitations of using AWS Lambdas as HTTP handlers.

Packaging Lambdas

Terminal: Invoking a Package Build

$ trv pack rest/lambda -h

Missing Package
--------------------
To use pack please run:

npm i --save-dev @travetto/pack

Extension - Model

To facilitate common RESTful patterns, the module exposes Data Modeling Support support in the form of ModelRoutes.

Code: ModelRoutes example

import { Inject } from '@travetto/di';
import { ModelCrudSupport } from '@travetto/model';
import { Controller, ModelRoutes } from '@travetto/rest';

import { User } from './user';

@Controller('/user')
@ModelRoutes(User)
class UserController {
  @Inject()
  source: ModelCrudSupport;
}

is a shorthand that is equal to:

Code: Comparable UserController, built manually

import { Inject } from '@travetto/di';
import { ModelCrudSupport } from '@travetto/model';
import { Path, Controller, Body, Get, Request, Delete, Post, Put } from '@travetto/rest';

import { User } from './user';

@Controller('/user')
class UserController {

  @Inject()
  service: ModelCrudSupport;

  @Get('')
  async getAllUser(req: Request) {
    return await this.service.list(User);
  }

  @Get(':id')
  async getUser(@Path() id: string) {
    return await this.service.get(User, id);
  }

  @Delete(':id')
  async deleteUser(@Path() id: string) {
    return await this.service.delete(User, id);
  }

  @Post('')
  async saveUser(@Body() user: User) {
    return await this.service.create(User, user);
  }

  @Put('')
  async updateUser(@Body() user: User) {
    return await this.service.update(User, user);
  }
}

Extension - Model Query

Additionally, Data Model Querying support can also be added support in the form of ModelQueryRoutes. This provides listing by query as well as an endpoint to facillitate suggestion behaviors.

Code: ModelQueryRoutes example

import { Inject } from '@travetto/di';
import { ModelQuerySupport } from '@travetto/model-query';
import { Controller, ModelQueryRoutes } from '@travetto/rest';

import { User } from './user';

@Controller('/user')
@ModelQueryRoutes(User)
class UserQueryController {
  @Inject()
  source: ModelQuerySupport;
}

is a shorthand that is equal to:

Code: Comparable UserController, built manually

import { Inject } from '@travetto/di';
import { ModelQuerySupport, SortClause, ValidStringFields } from '@travetto/model-query';
import { isQuerySuggestSupported } from '@travetto/model-query/src/internal/service/common';
import { Controller, Get } from '@travetto/rest';
import { RestModelQuery, RestModelSuggestQuery } from '@travetto/rest/src/extension/model-query';

import { User } from './user';

const convert = <T>(k?: string) => k && typeof k === 'string' && /^[\{\[]/.test(k) ? JSON.parse(k) as T : k;

@Controller('/user')
class UserQueryController {

  @Inject()
  service: ModelQuerySupport;

  @Get('')
  async getAllUser(query: RestModelQuery) {
    return this.service.query(User, {
      limit: query.limit,
      offset: query.offset,
      sort: convert(query.sort) as SortClause<User>[],
      where: convert(query.where)
    });
  }

  @Get('/suggest/:field')
  async suggest(field: ValidStringFields<User>, suggest: RestModelSuggestQuery) {
    if (isQuerySuggestSupported(this.service)) {
      return this.service.suggest(User, field, suggest.q, suggest);
    }
  }
}