add host option for @Controller

This commit is contained in:
Stepan Riha
2019-09-17 11:53:50 -05:00
parent cc35ddc55b
commit 7e3f3d8ada
19 changed files with 330 additions and 68 deletions

View File

@@ -40,6 +40,17 @@ describe('Hello world (express instance)', () => {
.expect('Hello world!');
});
it(`/GET { host: ":tenant.example.com" } not matched`, () => {
return request(server)
.get('/host')
.expect(404)
.expect({
statusCode: 404,
error: 'Not Found',
message: 'Cannot GET /host',
});
});
afterEach(async () => {
await app.close();
});

View File

@@ -5,6 +5,7 @@ import {
import { Test } from '@nestjs/testing';
import { expect } from 'chai';
import { ApplicationModule } from '../src/app.module';
import { fail } from 'assert';
describe('Hello world (fastify adapter)', () => {
let app: NestFastifyApplication;
@@ -47,6 +48,27 @@ describe('Hello world (fastify adapter)', () => {
.then(({ payload }) => expect(payload).to.be.eql('Hello world!'));
});
it(`/GET { host: ":tenant.example.com" } not matched`, () => {
return app
.inject({
method: 'GET',
url: '/host',
})
.then(
({ payload }) => {
fail(`Unexpected success: ${payload}`);
},
err => {
expect(err.message).to.be.eql({
error: 'Internal Server Error',
message:
'HTTP Adapter does not support filtering on { host: "host.example.com" }',
statusCode: 500,
});
},
);
});
afterEach(async () => {
await app.close();
});

View File

@@ -10,42 +10,60 @@ describe('Hello world (default adapter)', () => {
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [ApplicationModule],
})
.compile();
}).compile();
app = module.createNestApplication();
server = app.getHttpServer();
await app.init();
});
describe('/GET', () => {
it(`should return "Hello world!"`, () => {
return request(server)
.get('/hello')
.expect(200)
.expect('Hello world!');
[
{
host: 'example.com',
path: '/hello',
greeting: 'Hello world!',
},
{
host: 'host.example.com',
path: '/host',
greeting: 'Host Greeting!',
},
].forEach(({ host, path, greeting }) => {
describe(`host=${host}`, () => {
describe('/GET', () => {
it(`should return "${greeting}"`, () => {
return request(server)
.get(path)
.set('Host', host)
.expect(200)
.expect(greeting);
});
it(`should attach response header`, () => {
return request(server)
.get(path)
.set('Host', host)
.expect(200)
.expect('Authorization', 'Bearer');
});
});
it(`/GET (Promise/async) returns "${greeting}"`, () => {
return request(server)
.get(`${path}/async`)
.set('Host', host)
.expect(200)
.expect(greeting);
});
it(`/GET (Observable stream) "${greeting}"`, () => {
return request(server)
.get(`${path}/stream`)
.set('Host', host)
.expect(200)
.expect(greeting);
});
});
it(`should attach response header`, () => {
return request(server)
.get('/hello')
.expect(200)
.expect('Authorization', 'Bearer');
});
});
it(`/GET (Promise/async)`, () => {
return request(server)
.get('/hello/async')
.expect(200)
.expect('Hello world!');
});
it(`/GET (Observable stream)`, () => {
return request(server)
.get('/hello/stream')
.expect(200)
.expect('Hello world!');
});
afterEach(async () => {

View File

@@ -10,15 +10,14 @@ describe('Hello world (default adapter)', () => {
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [ApplicationModule],
})
.compile();
}).compile();
app = module.createNestApplication();
server = app.getHttpServer();
await app.init();
});
it(`should execute locally injected pipe`, () => {
it(`host=example.com should execute locally injected pipe by HelloController`, () => {
return request(server)
.get('/hello/local-pipe/1')
.expect(200)
@@ -27,6 +26,28 @@ describe('Hello world (default adapter)', () => {
});
});
it(`host=host.example.com should execute locally injected pipe by HostController`, () => {
return request(server)
.get('/host/local-pipe/1')
.set('Host', 'host.example.com')
.expect(200)
.expect({
id: '1',
host: true,
});
});
it(`should return 404 for mismatched host`, () => {
return request(server)
.get('/host/local-pipe/1')
.expect(404)
.expect({
error: 'Not Found',
message: 'Cannot GET /host/local-pipe/1',
statusCode: 404,
});
});
afterEach(async () => {
await app.close();
});

View File

@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common';
import { HelloModule } from './hello/hello.module';
import { HostModule } from './host/host.module';
@Module({
imports: [HelloModule],
imports: [HelloModule, HostModule],
})
export class ApplicationModule {}

View File

@@ -0,0 +1,10 @@
import { IsString, IsNotEmpty, IsNumber } from 'class-validator';
export class TestDto {
@IsString()
@IsNotEmpty()
string: string;
@IsNumber()
number: number;
}

View File

@@ -0,0 +1,36 @@
import { Controller, Get, Header, Param } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { HostService } from './host.service';
import { UserByIdPipe } from './users/user-by-id.pipe';
@Controller({
path: 'host',
host: 'host.example.com',
})
export class HostController {
constructor(private readonly hostService: HostService) {}
@Get()
@Header('Authorization', 'Bearer')
greeting(): string {
return this.hostService.greeting();
}
@Get('async')
async asyncGreeting(): Promise<string> {
return await this.hostService.greeting();
}
@Get('stream')
streamGreeting(): Observable<string> {
return of(this.hostService.greeting());
}
@Get('local-pipe/:id')
localPipe(
@Param('id', UserByIdPipe)
user: any,
): any {
return user;
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HostController } from './host.controller';
import { HostService } from './host.service';
import { UsersService } from './users/users.service';
@Module({
controllers: [HostController],
providers: [HostService, UsersService],
})
export class HostModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class HostService {
greeting(): string {
return 'Host Greeting!';
}
}

View File

@@ -0,0 +1,11 @@
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
import { UsersService } from './users.service';
@Injectable()
export class UserByIdPipe implements PipeTransform<string> {
constructor(private readonly usersService: UsersService) {}
transform(value: string, metadata: ArgumentMetadata) {
return this.usersService.findById(value);
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
findById(id: string) {
return { id, host: true };
}
}

View File

@@ -7,6 +7,7 @@ export const METADATA = {
export const SHARED_MODULE_METADATA = '__module:shared__';
export const GLOBAL_MODULE_METADATA = '__module:global__';
export const HOST_METADATA = 'host';
export const PATH_METADATA = 'path';
export const PARAMTYPES_METADATA = 'design:paramtypes';
export const SELF_DECLARED_DEPS_METADATA = 'self:paramtypes';

View File

@@ -1,4 +1,8 @@
import { PATH_METADATA, SCOPE_OPTIONS_METADATA } from '../../constants';
import {
HOST_METADATA,
PATH_METADATA,
SCOPE_OPTIONS_METADATA,
} from '../../constants';
import { isString, isUndefined } from '../../utils/shared.utils';
import { ScopeOptions } from './../../interfaces/scope-options.interface';
@@ -15,6 +19,15 @@ export interface ControllerOptions extends ScopeOptions {
* @see [Routing](https://docs.nestjs.com/controllers#routing)
*/
path?: string;
/**
* Specifies an optional HTTP Request host filter. When configured, methods
* within the controller will only be routed if the request host matches the
* specified value.
*
* @see [Routing](https://docs.nestjs.com/controllers#routing)
*/
host?: string;
}
/**
@@ -127,14 +140,19 @@ export function Controller(
prefixOrOptions?: string | ControllerOptions,
): ClassDecorator {
const defaultPath = '/';
const [path, scopeOptions] = isUndefined(prefixOrOptions)
? [defaultPath, undefined]
const [path, host, scopeOptions] = isUndefined(prefixOrOptions)
? [defaultPath, undefined, undefined]
: isString(prefixOrOptions)
? [prefixOrOptions, undefined]
: [prefixOrOptions.path || defaultPath, { scope: prefixOrOptions.scope }];
? [prefixOrOptions, undefined, undefined]
: [
prefixOrOptions.path || defaultPath,
prefixOrOptions.host,
{ scope: prefixOrOptions.scope },
];
return (target: object) => {
Reflect.defineMetadata(PATH_METADATA, path, target);
Reflect.defineMetadata(HOST_METADATA, host, target);
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, scopeOptions, target);
};
}

View File

@@ -3,19 +3,45 @@ import { Controller } from '../../decorators/core/controller.decorator';
describe('@Controller', () => {
const reflectedPath = 'test';
const reflectedHost = 'api.example.com';
@Controller(reflectedPath)
class Test {}
@Controller()
class EmptyDecorator {}
@Controller({ path: reflectedPath, host: reflectedHost })
class PathAndHostDecorator {}
@Controller({ host: reflectedHost })
class HostOnlyDecorator {}
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);
});
it('should enhance controller with expected host metadata', () => {
const host = Reflect.getMetadata('host', PathAndHostDecorator);
expect(host).to.be.eql(reflectedHost);
const host2 = Reflect.getMetadata('host', HostOnlyDecorator);
expect(host2).to.be.eql(reflectedHost);
});
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('/');
});
it('should not set host when no host passed as param', () => {
const host = Reflect.getMetadata('host', Test);
expect(host).to.be.undefined;
const host2 = Reflect.getMetadata('host', EmptyDecorator);
expect(host2).to.be.undefined;
});
});

View File

@@ -8,8 +8,8 @@ export const MODULE_INIT_MESSAGE = (
export const ROUTE_MAPPED_MESSAGE = (path: string, method: string | number) =>
`Mapped {${path}, ${RequestMethod[method]}} route`;
export const CONTROLLER_MAPPING_MESSAGE = (name: string, path: string) =>
`${name} {${path}}:`;
export const CONTROLLER_MAPPING_MESSAGE = (name: string, host: string, path: string) =>
`${name} {${host || '<any>'}, ${path}}:`;
export const INVALID_EXECUTION_CONTEXT = (
methodName: string,

View File

@@ -1,6 +1,7 @@
import { HttpServer } from '@nestjs/common';
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { RequestMethod } from '@nestjs/common/enums/request-method.enum';
import { InternalServerErrorException } from '@nestjs/common/exceptions';
import { Controller } from '@nestjs/common/interfaces/controllers/controller.interface';
import { Type } from '@nestjs/common/interfaces/type.interface';
import { Logger } from '@nestjs/common/services/logger.service';
@@ -33,6 +34,7 @@ import { REQUEST_CONTEXT_ID } from './request/request-constants';
import { RouteParamsFactory } from './route-params-factory';
import { RouterExecutionContext } from './router-execution-context';
import { RouterProxy, RouterProxyCallback } from './router-proxy';
import * as pathToRegexp from 'path-to-regexp';
export interface RoutePathProperties {
path: string[];
@@ -72,6 +74,7 @@ export class RouterExplorer {
module: string,
applicationRef: T,
basePath: string,
host: string,
) {
const { instance } = instanceWrapper;
const routerPaths = this.scanForPaths(instance);
@@ -81,6 +84,7 @@ export class RouterExplorer {
instanceWrapper,
module,
basePath,
host,
);
}
@@ -146,6 +150,7 @@ export class RouterExplorer {
instanceWrapper: InstanceWrapper,
module: string,
basePath: string,
host: string,
) {
(routePaths || []).forEach(pathProperties => {
const { path, requestMethod } = pathProperties;
@@ -155,6 +160,7 @@ export class RouterExplorer {
instanceWrapper,
module,
basePath,
host,
);
path.forEach(p =>
this.logger.log(ROUTE_MAPPED_MESSAGE(p, requestMethod)),
@@ -168,6 +174,7 @@ export class RouterExplorer {
instanceWrapper: InstanceWrapper,
moduleKey: string,
basePath: string,
host: string,
) {
const {
path: paths,
@@ -185,35 +192,56 @@ export class RouterExplorer {
const isRequestScoped = !instanceWrapper.isDependencyTreeStatic();
const module = this.container.getModuleByKey(moduleKey);
const handler = isRequestScoped
? this.createRequestScopedHandler(
instanceWrapper,
requestMethod,
module,
moduleKey,
methodName,
)
: this.createCallbackProxy(
instance,
targetCallback,
methodName,
moduleKey,
requestMethod,
);
const hostHandler = this.applyHostFilter(host, handler);
if (isRequestScoped) {
const handler = this.createRequestScopedHandler(
instanceWrapper,
requestMethod,
module,
moduleKey,
methodName,
);
paths.forEach(path => {
const fullPath = stripSlash(basePath) + path;
routerMethod(stripSlash(fullPath) || '/', handler);
});
return;
}
const proxy = this.createCallbackProxy(
instance,
targetCallback,
methodName,
moduleKey,
requestMethod,
);
paths.forEach(path => {
const fullPath = stripSlash(basePath) + path;
routerMethod(stripSlash(fullPath) || '/', proxy);
routerMethod(stripSlash(fullPath) || '/', hostHandler);
});
}
private applyHostFilter(host, handler) {
if (!host) {
return (req, res, next) => {
req.hosts = {};
return handler(req, res, next);
};
}
const httpAdapterRef = this.container.getHttpAdapterRef();
const keys = [];
const re = pathToRegexp(host, keys);
return (req, res, next) => {
req.hosts = {};
const hostname = httpAdapterRef.getRequestHostname(req) || '';
const match = hostname.match(re);
if (match) {
keys.forEach((key, i) => req.hosts[key.name] = match[i + 1]);
return handler(req, res, next);
}
if (!next) {
throw new InternalServerErrorException(`HTTP Adapter does not support filtering on { host: "${host}" }`);
}
return next();
};
}
private createCallbackProxy(
instance: Controller,
callback: RouterProxyCallback,

View File

@@ -1,5 +1,5 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { MODULE_PATH } from '@nestjs/common/constants';
import { HOST_METADATA, MODULE_PATH } from '@nestjs/common/constants';
import { HttpServer, Type } from '@nestjs/common/interfaces';
import { Controller } from '@nestjs/common/interfaces/controllers/controller.interface';
import { Logger } from '@nestjs/common/services/logger.service';
@@ -60,18 +60,19 @@ export class RoutesResolver implements Resolver {
) {
routes.forEach(instanceWrapper => {
const { metatype } = instanceWrapper;
const host = Reflect.getMetadata(HOST_METADATA, metatype);
const path = this.routerBuilder.extractRouterPath(
metatype as Type<any>,
basePath,
);
const controllerName = metatype.name;
this.logger.log(CONTROLLER_MAPPING_MESSAGE(controllerName, path));
this.logger.log(CONTROLLER_MAPPING_MESSAGE(controllerName, host, path));
this.routerBuilder.explore(
instanceWrapper,
moduleName,
applicationRef,
path,
host,
);
});
}

View File

@@ -114,7 +114,7 @@ describe('RouterExplorer', () => {
{ path: ['foo', 'bar'], requestMethod: RequestMethod.GET },
];
routerBuilder.applyPathsToRouterProxy(null, paths as any, null, '', '');
routerBuilder.applyPathsToRouterProxy(null, paths as any, null, '', '', '');
expect(bindStub.calledWith(null, paths[0], null)).to.be.true;
expect(bindStub.callCount).to.be.eql(paths.length);

View File

@@ -20,6 +20,12 @@ describe('RoutesResolver', () => {
public anotherTest() {}
}
@Controller({ host: 'api.example.com' })
class TestHostRoute {
@Get()
public getTest() {}
}
@Module({
controllers: [TestRoute],
})
@@ -63,7 +69,7 @@ describe('RoutesResolver', () => {
});
describe('registerRouters', () => {
it('should method register controllers to router instance', () => {
it('should register controllers to router instance', () => {
const routes = new Map();
const routeWrapper = new InstanceWrapper({
instance: new TestRoute(),
@@ -88,6 +94,32 @@ describe('RoutesResolver', () => {
expect(exploreSpy.calledWith(routeWrapper, moduleName, appInstance, ''))
.to.be.true;
});
it('should register with host when specified', () => {
const routes = new Map();
const routeWrapper = new InstanceWrapper({
instance: new TestHostRoute(),
metatype: TestHostRoute,
});
routes.set('TestHostRoute', routeWrapper);
const appInstance = new NoopHttpAdapter(router);
const exploreSpy = sinon.spy(
(routesResolver as any).routerBuilder,
'explore',
);
const moduleName = '';
modules.set(moduleName, {});
sinon
.stub((routesResolver as any).routerBuilder, 'extractRouterPath')
.callsFake(() => '');
routesResolver.registerRouters(routes, moduleName, '', appInstance);
expect(exploreSpy.called).to.be.true;
expect(exploreSpy.calledWith(routeWrapper, moduleName, appInstance, '', 'api.example.com'))
.to.be.true;
});
});
describe('resolve', () => {