@travetto/rest
v5.0.17
Published
Declarative api for RESTful APIs with support for the dependency injection module.
Downloads
181
Maintainers
Readme
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 Request and Response 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 controllerdescription
- 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 endpointdescription
- High level description fo the endpointresponseType?
- Class describing the response typerequestType?
- Class describing the request body JSDoc comments can also be used to define thetitle
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 class
es. 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. Request, Response, etc.) Each @Param can be configured to indicate:
name
- Name of param, field name, defaults to handler parameter name if necessarydescription
- Description of param, pulled from JSDoc, or defaults to name if emptyrequired?
- Is the field required?, defaults to whether or not the parameter itself is optionaltype
- 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() @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 };
}
}
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 interface
s 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 Runtime module, the following types automatically have rest support as well:
Error
- Serializes to a standard object, with status, and the error message.AppError
- Serializes likeError
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: './doc-exec'
},
workspace: {
name: '@travetto-doc/rest',
path: './doc-exec',
mono: false,
manager: 'npm',
type: 'commonjs',
defaultEnv: 'local'
}
},
runtime: {
env: '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: '1mb', 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/runtime';
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: './doc-exec'
},
workspace: {
name: '@travetto-doc/rest',
path: './doc-exec',
mono: false,
manager: 'npm',
type: 'commonjs',
defaultEnv: 'local'
}
},
runtime: {
env: '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: '1mb', 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 {
dependsOn = [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 = '1mb';
/**
* 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 Request and Response 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 Rest Upload 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 Request/Response 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 RuntimeResources.
Full Config
The entire RestConfig which will show the full set of valid configuration parameters for the rest module.