ts-api-decorators

API Decorators for typescript


Keywords
apis, services, typescript
License
Apache-2.0
Install
npm install ts-api-decorators@0.2.18

Documentation

Typescript API Decorators

This library provides a simple way to use typescript decorators to define APIs. The benefits of this approach are many-fold:

  • Automatic Runtime Type Safety and Validation: The library automatically checks that inputs you accept comply with the type definitions in your code. Extended validation also supports deep, customizable validation that helps you simplify your handlers while making them robust.
  • Easy Logging and Security: A built in hook system allows you to easily write functions that execute around your handlers - providing simple ways to perform logging or integrate security mechanisms.
  • Platform-Agnostic Implementation: You can easily compile the same API code to work in many environments: an Express web server, an Azure Function, ...
  • Automatic Swagger Generation: The library provides tools to automatically generate swagger definitions for your API, which can be used to automatically generate client SDKs.
  • API Validation: The library can be run in a type checking mode that ensures that all API responses conform to specification.

Quick Start

The fastest way to get started is to check out the docs or samples for the transport/hosting technology that you use:

Or you can read on to learn about the capabilities.

Contents

  1. Basic Usage
    1. Query string parameters
    2. Body parameters
    3. Headers
    4. Error Handling
    5. Asynchronous Patterns
  2. Dependency Injection
  3. Advanced Validation (WIP)
  4. Custom Hooks (WIP)

Usage (Defining an API)

APIs are defined as methods on a class:

@Api
class MyApi {
	@ApiGetMethod('/hello')
	greet() {
		return 'Hello World!';
	}
}

This defines an API that exposes a single GET handler at /hello that returns the string Hello World!. To hook this up with your desired transport you'll need to pick and install one of the supported transport packages:

You can also write your own to hook up to your preferred environment.

Defining an API that takes a query string parameter

An API can accept parameters in the querystring:

@Api
class MyApi {
	@ApiGetMethod('/hello')
	greet(
		@ApiQueryParam() name: string
	) {
		return `Hello ${name}!`;
	}
}

When this API is called at /hello?name=SuperDeveloper the following response is returned:

> GET /hello?name=SuperDeveloper
200 OK
Hello SuperDeveloper!

However, because the query parameter is required, then if this param is ommited the API returns:

> /hello
400 Bad Request
Missing query parameter 'name'

We can make the parameter optional by changing the function argument to be optional:

@Api
class MyApi {
	@ApiGetMethod('/hello')
	greet(
		@ApiQueryParam() name?: string
	) {
		if (name) {
			return `Hello ${name}!`;
		}

		return 'Hello!';
	}

}
> GET /hello
200 OK
Hello!

Robust type checking

Because ManagedApis have robust type checking and casting, we can also take other value-type parameters in query strings:

@Api
class MyApi {
	@ApiGetMethod('/hello')
	greet(
		@ApiQueryParam() name: string,
		@ApiQueryParam() times: number = 1,
	) {
		for (let i = 0; i < times; ++i) {
			return `Hi ${name}! `;
		}
	}
}

This defines an optional times parameter that specifies the number of times to greet. The parameter is optional because it has a default value (you can also specify times?: number to make it optional in the definition).

Note that the name parameter is not optional because in the function definition it is required.

You can also perform more robust checking on the values:

@Api
class MyApi {
	@ApiGetMethod('/hello')
	greet(
		@ApiQueryParamString(/^[a-zA-Z]{2,100}$/) name: string,
		@ApiQueryParamNumber(1, 10, 5) times?: number,
	) {
		for (const i = 0; i < times; ++i) {
			return `Hi ${name}! `;
		}
	}
}

This will assert that the name parameter is required and that it matches the regular expression ^[a-zA-Z]{2,100}$, and that the number parameter is optional and must be between 1 and 10 (inclusive). If the number parameter is omitted, it will be set to 5.

You can also use the Date type in query string parameters:

@Api
class MyApi {
	@ApiGetMethod('/day')
	greet(
		@ApiQueryParam() date: Date,
	) {
		return `The specified day is ${date.toDateString()}`;
	}
}

Examples:

> GET /day?date=1566329594
200 Ok
The specified day is Tue Aug 20 2019

> GET /day?date=2019-08-02T05:17:53
200 Ok
The specified day is Fri Aug 02 2019

> GET /day
400 Bad Request
Missing query parameter 'date'

> GET /day?date=
400 Bad Request
Invalid value for query parameter 'date'. Must be a valid date string.

> GET /day?date=My2019Custom08Format02
400 Bad Request
Invalid value for query parameter 'date'. Must be a valid date string.

Defining an API that takes a body parameter

APIs can also accept complex body parameters. Take the following API which expects an object in the response:

interface IBodyContents {
	name: string;
}

@Api
class MyApi {
	@ApiPostMethod('/hello')
	greet(
		@ApiBodyParam() contents: IBodyContents
	) {
		return `Hello ${contents.name}!`;
	}
}

When called with the following:

POST /hello
{ "name": "SuperDev" }

The API returns:

200 Ok
Hello SuperDev!

However if called with the following:

POST /hello
{ "myName": "SuperDev" }

The API returns:

400 Bad Request
Parameter '$.name' missing in body.

This also supports complex, multi-level objects:

interface IOccupation {
	profession: string;
	title: string;
}

interface IBodyContents {
	name: string;
	formalGreeting?: boolean;
	occupation: IOccupation;
}

@Api
class MyApi {
	@ApiPostMethod('/hello')
	greet(
		@ApiBodyParam() contents: IBodyContents
	) {
		if (contents.formalGreeting) {
			return `Greetings ${contents.name} (${contents.occupation.title})`;
		} else {
			return `Howdy ${contents.name}! I see you're an ${contents.occupation.profession}. Welcome!`;
		}
	}
}

Calling with the following body:

POST /hello
{ "name": "Mike", "occupation": { "profession": "Developer", "title": "Sr. Developer" } }

Results in the following response:

200 Ok
Greetings Mike (Sr. Developer)

Importantly, the API does deep validation on the input to ensure that it confirms to the typescript type you've defined:

POST /hello
{ "name": "Mike", "occupation": { "profession": "Developer" }, "formalGreeting": "yes" }
400 Bad Request
Parameter $.formalGreeting is expected to be a boolean.
Parameter $.occupation.title is missing.

Working with headers

Because different platforms have different ways of getting and setting headers, we provide a simple way to access them:

import { ManagedApi } from 'ts-api-decorators-*';

@Api
class MyApi {
	// Using the `getHeader` / `setHeader` functions:
	@ApiGetMethod('/hello')
	greet() {
		const greetHeader = ManagedApi.getHeader('x-name');
		ManagedApi.setHeader('x-name-response', greetHeader);
		return `Hello ${greetHeader}!`;
	}

	// Using the `ApiHeaderParam` descriptor
	@ApiGetMethod('/hello2')
	greet2(
		@ApiHeaderParam('x-name') greetHeader: string
	) {
		ManagedApi.setHeader('x-name-response', greetHeader);
		return `Hello ${greetHeader}!`;
	}
}

Example:

> GET /hello
X-Name: HeaderDev

200 Ok
X-Name-Response: HeaderDev
Hello HeaderDev!

Error Handling

ManagedApis handle errors using thrown or passed exceptions. When you throw an exception, managed api will look for a statusCode or code value on the thrown exception that will give an HTTP status code to be returned. When this is present the error can be auto formatted.

We also provide some standard exceptions that can be used.

import { HttpBadRequestException } from 'ts-api-decorators-*';

@Api
class MyApi {
	@ApiPostMethod('/hello')
	greet(@ApiQueryParam() name: string) {
		if (name.length >= 10) {
			throw new HttpBadRequestException('name must be fewer than 10 characters');
		}

		return `Hello ${name}!`;
	}
}

Example:

> GET /hello?name=InigoMontoya

400 Bad Request
name must be 10 or fewer characters

You can also create your own exception classes:

import {HttpError} from 'ts-api-decorators-*';

class HttpImATeapotError extends HttpError {
    constructor(m: string = "I'm a teapot") {
        super(m, 418, {});

        // Must set the prototype explicitly!
        Object.setPrototypeOf(this, HttpImATeapotError.prototype);
    }
}

JSON Error Responses

Note! This section details features which may not be fully implemented. If you'd prefer the API return detailed json responses for errors, this is simple:

import { ManagedApi, ApiErrorFormatterJsonDetailed } from 'ts-api-decorators-*';
const api = new ManagedApi({
	errorFormatter: ApiErrorFormatterJsonDetailed,
});

Then, using the previous example the errors become:

POST /hello
{ "name": "Mike", "occupation": { "profession": "Developer" }, "formalGreeting": "yes" }
400 Bad Request
{
	"code": 400,
	"message": "Bad Request",
	"errors": [
		{
			"type": "ValidationError",
			"parameter": "$.formalGreeting",
			"error": "$.formalGreeting is expected to be a boolean."
		},
		{
			"type": "ValidationError",
			"parameter": "$.occupation.title",
			"error": "$.occupation.title is missing."
		},
	]
}

The following error formatters are in development:

  • ApiErrorFormatterJsonDetailed
  • ApiErrorFormatterJsonSimple

Asyncronous Patterns

Two asyncronous patterns are supported for handlers: promises and callbacks. Promises are the preferred pattern.

Promises

ManagedApi supports async/await and Promises out of the box:

@Api
class MyApi {

	@ApiInjectedDependency
	private db: MyPromiseDatabase;

	@ApiGetMethod('/hello')
	async greet() {
		return await this.db.getDefaultGreeting();
	}

}

Rejected promises are also handled gracefully.

Callbacks

ManagedApi supports callbacks using the ApiCallback decorator:

@Api
class MyApi {

	@ApiInjectedDependency
	private db: MyCallbackDatabase;

	@ApiGetMethod<void, IGreetingResponse>('/hello')
	greet(@ApiCallback() callback: ApiMethodCallbackFunction<IGreetingResponse>) {
		this.db.getDefaultGreeting((err, result) => {
			if (err) {
				callback(err);
			} else {
				callback(null, result.greeting);
			}
		});
	}

}

Dependency Injection

When writing an api you often need to inject dependencies (such as a database connection) that are used to complete the request. ManagedApi can take care of this for you:

// A Dependency
class MyDatabase {
	public get(greeting: string) {
		switch (greeting) {
			case 'cowboy-greeting':
				return 'Howdy!';

			default:
				return 'Hello!';
		}
	}
}

@Api
class MyApi {
	// Injected property
	@ApiInjectedDependency()
	private db: MyDatabase;

	@ApiGetMethod('/hello')
	greet() {
		return this.db.get('cowboy-greeting');
	}
}

@Api
class MyApi {
	// Injected in constructor
	constructor(
		@ApiInjectedDependencyParam()
		private readonly db: MyDatabase;
	) {}

	@ApiGetMethod('/hello')
	greet() {
		return this.db.get('cowboy-greeting');
	}
}

@Api
class MyApi {

	// Injected when calling handler
	@ApiGetMethod('/hello')
	greet(
		@ApiInjectedDependencyParam() db: MyDatabase;
	) {
		return db.get('cowboy-greeting');
	}
}


const api = new ManagedApi();

// Register the depedency
api.addDependency(MyDatabase);

Because of the @ApiInjectedDependency on the MyApi class, and the registration for a matching dependency on the ManagedApi, ManagedApi will ensure that the dependency is available before the API is invoked. If the dependency hadn't been registered an error would be thrown during initialization because the dependency couldn't be initialized.

By default, dependencies are initialized just-in-time - meaning that they're only instantiated when needed to respond to a request. A future update will make this behavior configurable to reduce cold-start possibilities.

Dependencies with Dependencies

Internally, ManagedApi creates a graph of dependencies. This means that you can have dependencies with dependencies:

class Database {
	constructor(
		@ApiInjectedDependencyParam() private readonly config: DbConfig
	) {}

	// ...
}

class DbConfig {
	// ...
}

const api = new ManagedApi();

// Register the depedency
api.addDependency(Database);
api.addDependency(DbConfig);

This will case DbConfig to be initialized before Database, and passed to its constructor and eventually injected into an API handler.

Scoped Dependencies

Note! This section details features which may not be fully implemented. You can also provide dependencies with a name in case you have multiple dependencies of the same type:

@Api
class MyApi {

	@ApiInjectedDependency('greetings')
	private greetingDb: MyDatabase;

	@ApiInjectedDependency('friends')
	private greetingDb: MyDatabase;

	// ...

}

const greetingsDb = new MyDatabase('db.greetings');
const friendsDb = new MyDatabase('db.friends');
const api = new ManagedApi();
api.addDependency(greetingsDb, 'greetings');
api.addDependency(friendssDb, 'friends');

This allows you to have multiple dependencies that have the same type, but may connect to different underlying resources. The dependency injector will also perform type checking before injection of a named dependency and will throw an exception if it is of an incompatible type.

Advanced Parameter Validation

Note! This section details features which may not be fully implemented. Body parameters support advanced validation in two ways: a IParamValidationDefinition, or a custom function:

Validation with IParamValidationDefinition

interface IBodyContents {
	phoneNumber: string;
	name: string;
	age: number;
}

const BodyContentsValidationDef: IParamValidationDefinition<IBodyContents> = {
	phoneNumber: {
		validationRegex: /^[2-9]\d{2}-\d{3}-\d{4}$/
	},
	name: {
		minLength: 2
	},
	age: {
		min: 13,
		max: 150
	}
}

@Api
class MyApi {

	@ApiPostMethod('/phoneNumber')
	greet(@ApiBodyParam(BodyContentsValidationDef) contents: IBodyContents) {
		// Contents will be validated against the definition and the types before
		// this function is invoked.
	}

}

Validation with Custom Function

interface IBodyContents {
	phoneNumber: string;
	name: string;
	age: number;
}

const BodyContentsValidationFunc = (contents: IBodyContents) => {
	// This function performs detailed validation for IBodyContents.
	// Type safety is already checked against the interface IBodyContents
	// before this function is executed.
	if (!contents.phoneNumber.match(/^[2-9]\d{2}-\d{3}-\d{4}$/)) {
		throw new HttpBadRequestException('Invalid phone number');
	}

	if (contents.name.length < 2) {
		throw new HttpBadRequestException('Name must be at least 2 characters');
	}

	if (contents.age < 13) {
		throw new HttpBadRequestException('Age must be at least 13');
	}
	
	if (contents.age > 150) {
		throw new HttpBadRequestException('Age must be at least 150');
	}
};

@Api
class MyApi {

	@ApiPostMethod('/phoneNumber')
	greet(@ApiBodyParam(BodyContentsValidationFunc) contents: IBodyContents) {
		// Contents will be validated by the functions and the types before
		// this function is invoked.
	}

}

Custom Hooks

Custom hooks execute at various points during the execution lifecycle and allow you to run custom code around the invocation of a function. The below example uses a stopwatch to time the execution of a handler:

function stopWatchStart(context: IApiInvocationContext) {
	context.stopWatchStart = Date.now();
	return context;
}

function stopWatchEnd(context: IApiInvocationContextPostInvoke) {
	const elapsed = Date.now() - context.stopWatchStart;
	console.log(`${context.apiDefinition.method} ${context.apiDefinition.path} execution took ${elapsed}ms`);
}

const api = new ManagedApi();
api.addHook('handler-preinvoke', stopWatchStart);
api.addHook('handler-postinvoke', stopWatchEnd);

Sample output:

GET /hello execution took 5ms
POST /customer/123 execution took 23ms
DELETE /invoice/456 execution took 17ms

The following hooks are available:

  • handler-preinvoke
  • handler-postinvoke