@travetto/rest

4.1.4 • Public • Published

RESTful API

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

Install: @travetto/rest

npm install @travetto/rest

# or

yarn add @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:

  • @Get
  • @Post
  • @Put
  • @Delete
  • @Patch
  • @Head
  • @Options 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:

  • @Path - Path params
  • @Query - Query params
  • @Body - Request body (in it's entirety), with support for validation
  • @Header - Header values
  • @Context - Special values exposed (e.g. TravettoRequest, TravettoResponse, etc.) 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/*')
  async getImage(
    req: Request,
    @Query('w') @Integer().Param @Min(100).Param width?: number,
    @Query('h') @Integer().Param @Min(100).Param 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 };
  }
}

The framework 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 Query + Schema for GET requests

import { Schema } from '@travetto/schema';
import { Controller, Get } 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(query: SearchParams) {
    return await this.service.search(query);
  }
}

Additionally, schema related inputs 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 } from '@travetto/rest';

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

@Controller('/user')
class UserController {

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

  @Get('/search')
  async search(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:
  • 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 provides a default @CliCommand for RestApplication that will follow default behaviors, and spin up the REST server.

Terminal: Standard application

$ trv run:rest

Initialized {
  manifest: {
    main: { name: '@travetto-doc/rest', folder: '' },
    workspace: {
      name: '@travetto-doc/rest',
      path: './doc-exec',
      mono: false,
      manager: 'npm',
      type: 'commonjs',
      defaultEnv: 'local'
    }
  },
  env: {
    name: 'local',
    debug: false,
    production: false,
    dynamic: false,
    resourcePaths: [ './doc-exec/resources' ],
    profiles: []
  },
  config: {
    sources: [ { priority: 999, source: 'memory://override' } ],
    active: {
      RestAcceptsConfig: { types: {} },
      RestAsyncContextConfig: {},
      RestBodyParseConfig: { limit: '100kb', parsingTypes: {} },
      RestConfig: {
        serve: true,
        port: 3000,
        trustProxy: false,
        hostname: 'localhost',
        bindAddress: '0.0.0.0',
        baseUrl: 'http://localhost:3000',
        defaultMessage: true
      },
      RestCookieConfig: { signed: true, httpOnly: true, sameSite: 'lax' },
      RestCorsConfig: {},
      RestGetCacheConfig: {},
      RestLogRoutesConfig: {},
      RestSslConfig: { active: false }
    }
  }
}
Listening { port: 3000 }

Creating a Custom CLI Entry Point

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

Code: Application entry point for Rest Applications

import { Env } from '@travetto/base';
import { CliCommand } from '@travetto/cli';
import { DependencyRegistry } from '@travetto/di';
import { RootRegistry } from '@travetto/registry';
import { RestApplication, RestSslConfig } from '@travetto/rest';

@CliCommand({ runTarget: true })
export class SampleApp {

  preMain(): void {
    Env.TRV_ENV.set('prod');
    Env.NODE_ENV.set('production');
  }

  async main() {
    console.log('CUSTOM STARTUP');
    await RootRegistry.init();
    const ssl = await DependencyRegistry.getInstance(RestSslConfig);
    ssl.active = true;

    // Configure server before running
    return DependencyRegistry.runInstance(RestApplication);
  }
}

And using the pattern established in the Command Line Interface module, you would run your program using npx trv run:rest:custom.

Terminal: Custom application

$ trv run:rest:custom

CUSTOM STARTUP
Initialized {
  manifest: {
    main: { name: '@travetto-doc/rest', folder: '' },
    workspace: {
      name: '@travetto-doc/rest',
      path: './doc-exec',
      mono: false,
      manager: 'npm',
      type: 'commonjs',
      defaultEnv: 'local'
    }
  },
  env: {
    name: 'prod',
    debug: false,
    production: true,
    dynamic: false,
    resourcePaths: [ './doc-exec/resources' ],
    profiles: []
  },
  config: {
    sources: [ { priority: 999, source: 'memory://override' } ],
    active: {
      RestAcceptsConfig: { types: {} },
      RestAsyncContextConfig: {},
      RestBodyParseConfig: { limit: '100kb', parsingTypes: {} },
      RestConfig: {
        serve: true,
        port: 3000,
        trustProxy: false,
        hostname: 'localhost',
        bindAddress: '0.0.0.0',
        baseUrl: 'http://localhost:3000',
        defaultMessage: true
      },
      RestCookieConfig: { signed: true, httpOnly: true, sameSite: 'lax' },
      RestCorsConfig: {},
      RestGetCacheConfig: {},
      RestLogRoutesConfig: {},
      RestSslConfig: { active: true }
    }
  }
}
Listening { port: 3000 }

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 Interceptor

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

@Injectable()
export class HelloWorldInterceptor implements RestInterceptor {

  after = [SerializeInterceptor];

  intercept(ctx: FilterContext) {
    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 requirements 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:

BodyParseInterceptor

BodyParseInterceptor handles the inbound request, and converting the body payload into an appropriate format.Additionally it exposes the original request as the raw property on the request.

Code: Body Parse Config

export class RestBodyParseConfig extends ManagedInterceptorConfig {
  /**
   * Max body size limit
   */
  limit: string = '100kb';
  /**
   * How to interpret different content types
   */
  parsingTypes: Record<string, ParserType> = {};
}

SerializeInterceptor

SerializeInterceptor 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.

CorsInterceptor

CorsInterceptor 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 extends ManagedInterceptorConfig {
  /**
   * Allowed origins
   */
  origins?: string[];
  /**
   * Allowed http methods
   */
  methods?: Request['method'][];
  /**
   * Allowed http headers
   */
  headers?: string[];
  /**
   * Support credentials?
   */
  credentials?: boolean;

  @Ignore()
  resolved: {
    origins: Set<string>;
    methods: string;
    headers: string;
    credentials: boolean;
  };
}

CookiesInterceptor

CookiesInterceptor 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 extends ManagedInterceptorConfig {
  /**
   * 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
   */
  @Secret()
  keys = ['default-insecure'];
  /**
   * Is the cookie only valid for https
   */
  secure?: boolean;
  /**
   * The domain of the cookie
   */
  domain?: string;
}

GetCacheInterceptor

GetCacheInterceptor 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.

LoggingInterceptor

LoggingInterceptor 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.log:
- '/controller1'
- '!/controller1:*'
- '/controller2:/path'
- '!/controller3:/path/*'

AsyncContextInterceptor

AsyncContextInterceptor is responsible for sharing context across the various layers that may be touched by a request. There is a negligible performance impact to the necessary booking keeping and so this interceptor can easily be disabled as needed.

Custom Interceptors

Additionally it is sometimes necessary to register custom interceptors. Interceptors can be registered with the Dependency Injection by implementing the RestInterceptor interface. 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 { FilterContext, 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 }: FilterContext) {
    // 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, FilterContext, FilterNext } from '@travetto/rest';
import { Injectable } from '@travetto/di';

@Injectable()
export class LoggingInterceptor implements RestInterceptor {
  async intercept(ctx: FilterContext, next: FilterNext) {
    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.

Configuring Interceptors

All framework-provided interceptors, follow the same patterns for general configuration. This falls into three areas:

Enable/disable of individual interceptors via configuration

Code: Sample interceptor disabling configuration

rest:
  cors:
    disabled: true

Path-based control for various routes within the application

Code: Sample interceptor path managed configuration

rest:
  cors:
    paths: 
      - '!/public/user'
      - '/public/*'

Route-enabled control via decorators

Code: Sample controller with route-level allow/deny

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

@Controller('/allowDeny')
@ConfigureInterceptor(CorsInterceptor, { disabled: true })
export class AlowDenyController {

  @Get('/override')
  @ConfigureInterceptor(CorsInterceptor, { disabled: false })
  cookies(@Query() value: string) {

  }
}

The resolution logic is as follows:

  • Determine if interceptor is disabled, this takes precedence.
  • Check the route against the path allow/deny list. If matched (positive or negative), this wins.
  • Finally check to see if the interceptor has custom applies logic. If it does, match against the configuration for the route.
  • By default, if nothing else matched, assume the interceptor is valid.

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 ResourceLoader.

Full Config

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

Package Sidebar

Install

npm i @travetto/rest

Homepage

travetto.io

Weekly Downloads

245

Version

4.1.4

License

MIT

Unpacked Size

150 kB

Total Files

51

Last publish

Collaborators

  • arcsine