Skip to content

Commit

Permalink
feat(common/core): api versioning
Browse files Browse the repository at this point in the history
adding the ability to have different versions of routes to support changing applications that still need to support legacy consumers

closes #5065
  • Loading branch information
rich-w-lee committed Feb 7, 2021
1 parent d5c51c1 commit ba60e4a
Show file tree
Hide file tree
Showing 39 changed files with 10,346 additions and 19 deletions.
1 change: 1 addition & 0 deletions packages/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export const HEADERS_METADATA = '__headers__';
export const REDIRECT_METADATA = '__redirect__';
export const RESPONSE_PASSTHROUGH_METADATA = '__responsePassthrough__';
export const SSE_METADATA = '__sse__';
export const VERSION_METADATA = '__version__';
22 changes: 18 additions & 4 deletions packages/common/decorators/core/controller.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import {
HOST_METADATA,
PATH_METADATA,
SCOPE_OPTIONS_METADATA,
VERSION_METADATA,
} from '../../constants';
import { ScopeOptions } from '../../interfaces/scope-options.interface';
import { VersionOptions } from '../../interfaces/version-options.interface';
import { isString, isUndefined } from '../../utils/shared.utils';

/**
* Interface defining options that can be passed to `@Controller()` decorator
*
* @publicApi
*/
export interface ControllerOptions extends ScopeOptions {
export interface ControllerOptions extends ScopeOptions, VersionOptions {
/**
* Specifies an optional `route path prefix`. The prefix is pre-pended to the
* path specified in any request decorator in the class.
Expand Down Expand Up @@ -97,10 +99,14 @@ export function Controller(prefix: string | string[]): ClassDecorator;
* more details.
* - `prefix` - string that defines a `route path prefix`. The prefix
* is pre-pended to the path specified in any request decorator in the class.
* - `version` - string, array of strings, or Symbol that defines the version
* of all routes in the class. [See Versioning](https://docs.nestjs.com/techniques/versioning)
* for more details.
*
* @see [Routing](https://docs.nestjs.com/controllers#routing)
* @see [Controllers](https://docs.nestjs.com/controllers)
* @see [Microservices](https://docs.nestjs.com/microservices/basics#request-response)
* @see [Versioning](https://docs.nestjs.com/techniques/versioning)
*
* @publicApi
*/
Expand Down Expand Up @@ -128,11 +134,15 @@ export function Controller(options: ControllerOptions): ClassDecorator;
* more details.
* - `prefix` - string that defines a `route path prefix`. The prefix
* is pre-pended to the path specified in any request decorator in the class.
* - `version` - string, array of strings, or Symbol that defines the version
* of all routes in the class. [See Versioning](https://docs.nestjs.com/techniques/versioning)
* for more details.
*
* @see [Routing](https://docs.nestjs.com/controllers#routing)
* @see [Controllers](https://docs.nestjs.com/controllers)
* @see [Microservices](https://docs.nestjs.com/microservices/basics#request-response)
* @see [Scope](https://docs.nestjs.com/fundamentals/injection-scopes#usage)
* @see [Versioning](https://docs.nestjs.com/techniques/versioning)
*
* @publicApi
*/
Expand All @@ -141,19 +151,23 @@ export function Controller(
): ClassDecorator {
const defaultPath = '/';

const [path, host, scopeOptions] = isUndefined(prefixOrOptions)
? [defaultPath, undefined, undefined]
const [path, host, scopeOptions, versionOptions] = isUndefined(
prefixOrOptions,
)
? [defaultPath, undefined, undefined, undefined]
: isString(prefixOrOptions) || Array.isArray(prefixOrOptions)
? [prefixOrOptions, undefined, undefined]
? [prefixOrOptions, undefined, undefined, undefined]
: [
prefixOrOptions.path || defaultPath,
prefixOrOptions.host,
{ scope: prefixOrOptions.scope },
prefixOrOptions.version,
];

return (target: object) => {
Reflect.defineMetadata(PATH_METADATA, path, target);
Reflect.defineMetadata(HOST_METADATA, host, target);
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, scopeOptions, target);
Reflect.defineMetadata(VERSION_METADATA, versionOptions, target);
};
}
1 change: 1 addition & 0 deletions packages/common/decorators/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './use-guards.decorator';
export * from './use-interceptors.decorator';
export * from './use-pipes.decorator';
export * from './apply-decorators';
export * from './version.decorator';
18 changes: 18 additions & 0 deletions packages/common/decorators/core/version.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { VERSION_METADATA } from '../../constants';
import { VersionValue } from '../../interfaces/version-options.interface';

/**
* Sets the version of the endpoint to the passed version
*
* @publicApi
*/
export function Version(version: VersionValue): MethodDecorator {
return (
target: any,
key: string | symbol,
descriptor: TypedPropertyDescriptor<any>,
) => {
Reflect.defineMetadata(VERSION_METADATA, version, descriptor.value);
return descriptor;
};
}
1 change: 1 addition & 0 deletions packages/common/enums/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './request-method.enum';
export * from './http-status.enum';
export * from './shutdown-signal.enum';
export * from './version-type.enum';
8 changes: 8 additions & 0 deletions packages/common/enums/version-type.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @publicApi
*/
export enum VersioningType {
URI,
HEADER,
MEDIA_TYPE,
}
2 changes: 2 additions & 0 deletions packages/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export {
Type,
ValidationError,
ValueProvider,
VersioningOptions,
VERSION_NEUTRAL,
WebSocketAdapter,
WsExceptionFilter,
WsMessageHandler,
Expand Down
1 change: 1 addition & 0 deletions packages/common/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export * from './nest-microservice.interface';
export * from './scope-options.interface';
export * from './type.interface';
export * from './websockets/web-socket-adapter.interface';
export * from './version-options.interface';
9 changes: 9 additions & 0 deletions packages/common/interfaces/nest-application.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from './index';
import { INestApplicationContext } from './nest-application-context.interface';
import { WebSocketAdapter } from './websockets/web-socket-adapter.interface';
import { VersioningOptions } from './version-options.interface';

/**
* Interface defining the core NestApplication object.
Expand All @@ -35,6 +36,14 @@ export interface INestApplication extends INestApplicationContext {
*/
enableCors(options?: CorsOptions | CorsOptionsDelegate<any>): void;

/**
* Enables Versioning for the application.
*
* @param {VersioningOptions} options
* @returns {this}
*/
enableVersioning(options: VersioningOptions): this;

/**
* Starts the application.
*
Expand Down
62 changes: 62 additions & 0 deletions packages/common/interfaces/version-options.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { VersioningType } from '../enums/version-type.enum';

/**
* Indicates that this will work for any version passed in the request, or no version.
*
* @publicApi
*/
export const VERSION_NEUTRAL = Symbol('VERSION_NEUTRAL');

export type VersionValue = string | string[] | typeof VERSION_NEUTRAL;

/**
* @publicApi
*/
export interface VersionOptions {
/**
* Specifies an optional API Version. When configured, methods
* withing the controller will only be routed if the request version
* matches the specified value.
*
* @see [Versioning](https://docs.nestjs.com/techniques/versioning)
*/
version?: VersionValue;
}

export interface HeaderVersioningOptions {
type: VersioningType.HEADER;
/**
* The name of the Request Header that contains the version.
*/
header: string;
}

export interface UriVersioningOptions {
type: VersioningType.URI;
/**
* Optional prefix that will prepend the version within the URI.
*
* Defaults to `v`.
*
* Ex. Assuming a version of `1`, for `/api/v1/route`, `v` is the prefix.
*/
prefix?: string | false;
}

export interface MediaTypeVersioningOptions {
type: VersioningType.MEDIA_TYPE;
/**
* The key within the Media Type Header to determine the version from.
*
* Ex. For `application/json;v=1`, the key is `v=`.
*/
key: string;
}

/**
* @publicApi
*/
export type VersioningOptions =
| HeaderVersioningOptions
| UriVersioningOptions
| MediaTypeVersioningOptions;
38 changes: 38 additions & 0 deletions packages/common/test/decorators/controller.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { expect } from 'chai';
import { VERSION_METADATA } from '../../constants';
import { Controller } from '../../decorators/core/controller.decorator';

describe('@Controller', () => {
const reflectedPath = 'test';
const reflectedHost = 'api.example.com';
const reflectedHostArray = ['api1.example.com', 'api2.example.com'];
const reflectedVersion = '1';

@Controller(reflectedPath)
class Test {}
Expand All @@ -21,11 +23,23 @@ describe('@Controller', () => {
@Controller({ host: reflectedHost })
class HostOnlyDecorator {}

@Controller({
path: reflectedPath,
host: reflectedHost,
version: reflectedVersion,
})
class PathAndHostAndVersionDecorator {}

@Controller({ version: reflectedVersion })
class VersionOnlyDecorator {}

it('should enhance controller with expected path metadata', () => {
const path = Reflect.getMetadata('path', Test);
expect(path).to.be.eql(reflectedPath);
const path2 = Reflect.getMetadata('path', PathAndHostDecorator);
expect(path2).to.be.eql(reflectedPath);
const path3 = Reflect.getMetadata('path', PathAndHostAndVersionDecorator);
expect(path3).to.be.eql(reflectedPath);
});

it('should enhance controller with expected host metadata', () => {
Expand All @@ -35,13 +49,30 @@ describe('@Controller', () => {
expect(host2).to.be.eql(reflectedHost);
const host3 = Reflect.getMetadata('host', PathAndHostArrayDecorator);
expect(host3).to.be.eql(reflectedHostArray);
const host4 = Reflect.getMetadata('host', PathAndHostAndVersionDecorator);
expect(host4).to.be.eql(reflectedHost);
});

it('should enhance controller with expected version metadata', () => {
const version = Reflect.getMetadata(
VERSION_METADATA,
PathAndHostAndVersionDecorator,
);
expect(version).to.be.eql(reflectedVersion);
const version2 = Reflect.getMetadata(
VERSION_METADATA,
VersionOnlyDecorator,
);
expect(version2).to.be.eql(reflectedVersion);
});

it('should set default path when no object passed as param', () => {
const path = Reflect.getMetadata('path', EmptyDecorator);
expect(path).to.be.eql('/');
const path2 = Reflect.getMetadata('path', HostOnlyDecorator);
expect(path2).to.be.eql('/');
const path3 = Reflect.getMetadata('path', VersionOnlyDecorator);
expect(path3).to.be.eql('/');
});

it('should not set host when no host passed as param', () => {
Expand All @@ -50,4 +81,11 @@ describe('@Controller', () => {
const host2 = Reflect.getMetadata('host', EmptyDecorator);
expect(host2).to.be.undefined;
});

it('should not set version when no version passed as param', () => {
const version = Reflect.getMetadata(VERSION_METADATA, Test);
expect(version).to.be.undefined;
const version2 = Reflect.getMetadata(VERSION_METADATA, EmptyDecorator);
expect(version2).to.be.undefined;
});
});
17 changes: 17 additions & 0 deletions packages/common/test/decorators/version.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect } from 'chai';
import { VERSION_METADATA } from '../../constants';
import { Version } from '../../decorators/core/version.decorator';

describe('@Version', () => {
const version = '1';

class Test {
@Version(version)
public static test() {}
}

it('should enhance method with expected version string', () => {
const metadata = Reflect.getMetadata(VERSION_METADATA, Test.test);
expect(metadata).to.be.eql(version);
});
});
10 changes: 10 additions & 0 deletions packages/core/application-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
NestInterceptor,
PipeTransform,
WebSocketAdapter,
VersioningOptions,
} from '@nestjs/common';
import { InstanceWrapper } from './injector/instance-wrapper';

Expand All @@ -13,6 +14,7 @@ export class ApplicationConfig {
private globalFilters: ExceptionFilter[] = [];
private globalInterceptors: NestInterceptor[] = [];
private globalGuards: CanActivate[] = [];
private versioning: VersioningOptions | null = null;
private readonly globalRequestPipes: InstanceWrapper<PipeTransform>[] = [];
private readonly globalRequestFilters: InstanceWrapper<ExceptionFilter>[] = [];
private readonly globalRequestInterceptors: InstanceWrapper<NestInterceptor>[] = [];
Expand Down Expand Up @@ -117,4 +119,12 @@ export class ApplicationConfig {
public getGlobalRequestGuards(): InstanceWrapper<CanActivate>[] {
return this.globalRequestGuards;
}

public enableVersioning(options: VersioningOptions): void {
this.versioning = options;
}

public getVersioning(): VersioningOptions | null {
return this.versioning;
}
}
26 changes: 26 additions & 0 deletions packages/core/helpers/messages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { RequestMethod } from '@nestjs/common/enums/request-method.enum';
import {
VERSION_NEUTRAL,
VersionValue,
} from '@nestjs/common/interfaces/version-options.interface';

export const MODULE_INIT_MESSAGE = (
text: TemplateStringsArray,
Expand All @@ -8,9 +12,31 @@ export const MODULE_INIT_MESSAGE = (
export const ROUTE_MAPPED_MESSAGE = (path: string, method: string | number) =>
`Mapped {${path}, ${RequestMethod[method]}} route`;

export const VERSIONED_ROUTE_MAPPED_MESSAGE = (
path: string,
method: string | number,
version: VersionValue,
) => {
if (version === VERSION_NEUTRAL) {
version = 'Neutral';
}
return `Mapped {${path}, ${RequestMethod[method]}}(Version: ${version}) route`;
};

export const CONTROLLER_MAPPING_MESSAGE = (name: string, path: string) =>
`${name} {${path}}:`;

export const VERSIONED_CONTROLLER_MAPPING_MESSAGE = (
name: string,
path: string,
version: VersionValue,
) => {
if (version === VERSION_NEUTRAL) {
version = 'Neutral';
}
return `${name} {${path}}(Version: ${version}):`;
};

export const INVALID_EXECUTION_CONTEXT = (
methodName: string,
currentContext: string,
Expand Down
Loading

0 comments on commit ba60e4a

Please sign in to comment.