Skip to content

Commit

Permalink
feat(rest): add requestedBaseUrl API to RequestContext
Browse files Browse the repository at this point in the history
Move the code calculating the requested protocol, base path and base URL
from RestServer private methods into RequestContext's public API.
  • Loading branch information
bajtos committed Mar 14, 2019
1 parent b83cddd commit 912bece
Show file tree
Hide file tree
Showing 7 changed files with 260 additions and 87 deletions.
17 changes: 17 additions & 0 deletions packages/rest/src/__tests__/helpers.ts
Expand Up @@ -10,6 +10,7 @@ import {
} from '@loopback/openapi-v3-types';
import {IncomingMessage} from 'http';
import {LogError} from '..';
import {RestServerConfig, RestServerResolvedConfig} from '../rest.server';

export function createUnexpectedHttpErrorLogger(
expectedStatusCode: number = 0,
Expand Down Expand Up @@ -51,3 +52,19 @@ export function aBodySpec(
});
return spec as RequestBodyObject;
}

export function aRestServerConfig(
customConfig?: RestServerConfig,
): RestServerResolvedConfig {
return Object.assign(
{
port: 3000,
openApiSpec: {disabled: true},
apiExplorer: {disabled: true},
cors: {},
expressSettings: {},
router: {},
},
customConfig,
);
}
Expand Up @@ -30,7 +30,7 @@ import {
UrlEncodedBodyParser,
writeResultToResponse,
} from '../..';
import {createUnexpectedHttpErrorLogger} from '../helpers';
import {createUnexpectedHttpErrorLogger, aRestServerConfig} from '../helpers';

const SequenceActions = RestBindings.SequenceActions;

Expand Down Expand Up @@ -652,7 +652,7 @@ describe('HttpHandler', () => {
.bind(RestBindings.REQUEST_BODY_PARSER)
.toClass(RequestBodyParser);

handler = new HttpHandler(rootContext);
handler = new HttpHandler(rootContext, aRestServerConfig());
rootContext.bind(RestBindings.HANDLER).to(handler);
}

Expand Down
130 changes: 130 additions & 0 deletions packages/rest/src/__tests__/integration/request-context.integration.ts
@@ -0,0 +1,130 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/rest
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {ApplicationConfig} from '@loopback/core';
import {
Client,
createRestAppClient,
expect,
givenHttpServerConfig,
supertest,
httpsGetAsync,
} from '@loopback/testlab';
import * as express from 'express';
import {RequestContext} from '../../request-context';
import {RestApplication} from '../../rest.application';
import {RestServerConfig} from '../../rest.server';
import {DefaultSequence} from '../../sequence';

let app: RestApplication;
let client: Client;
let observedCtx: RequestContext;

describe('RequestContext', () => {
beforeEach(setup);
afterEach(teardown);

describe('requestedProtocol', () => {
it('defaults to "http"', async () => {
await givenRunningAppWithClient();
await client.get('/products').expect(200);
expect(observedCtx.requestedProtocol).to.equal('http');
});

it('honors "x-forwarded-proto" header', async () => {
await givenRunningAppWithClient();
await client
.get('/products')
.set('x-forwarded-proto', 'https')
.expect(200);
expect(observedCtx.requestedProtocol).to.equal('https');
});

it('honors protocol provided by Express request', async () => {
await givenRunningAppWithClient({protocol: 'https'});
expect(app.restServer.url).to.startWith('https:');
// supertest@3 fails with Error: self signed certificate
// FIXME(bajtos) rework this code once we upgrade to supertest@4
// await client.get('/products').trustLocalhost().expect(200);
await httpsGetAsync(app.restServer.url + '/products');
expect(observedCtx.requestedProtocol).to.equal('https');
});
});

describe('basePath', () => {
it('defaults to an empty string', async () => {
await givenRunningAppWithClient();
await client.get('/products').expect(200);
expect(observedCtx.basePath).to.equal('');
});

it('honors baseUrl when mounted on a sub-path', async () => {
const lbApp = new RestApplication();
lbApp.handler(contextObservingHandler);

const expressApp = express();
expressApp.use('/api', lbApp.requestHandler);

await supertest(expressApp)
.get('/api/products')
.expect(200);

expect(observedCtx.basePath).to.equal('/api');
});
});

describe('requestedBaseUrl', () => {
it('defaults to data from the HTTP connection', async () => {
await givenRunningAppWithClient({
host: undefined,
port: 0,
});
const serverUrl = app.restServer.url;

await client.get('/products').expect(200);

expect(observedCtx.requestedBaseUrl).to.equal(serverUrl);
});

it('honors "x-forwarded-*" headers', async () => {
await givenRunningAppWithClient();
await client
.get('/products')
.set('x-forwarded-proto', 'https')
.set('x-forwarded-host', 'example.com')
.set('x-forwarded-port', '8080')
.expect(200);
expect(observedCtx.requestedBaseUrl).to.equal('https://example.com:8080');
});
});
});

function setup() {
(app as unknown) = undefined;
(client as unknown) = undefined;
(observedCtx as unknown) = undefined;
}

async function teardown() {
if (app) await app.stop();
}

async function givenRunningAppWithClient(restOptions?: RestServerConfig) {
const options: ApplicationConfig = {
rest: givenHttpServerConfig(restOptions),
};
app = new RestApplication(options);
app.handler(contextObservingHandler);
await app.start();
client = createRestAppClient(app);
}

function contextObservingHandler(
ctx: RequestContext,
_sequence: DefaultSequence,
) {
observedCtx = ctx;
ctx.response.end('ok');
}
5 changes: 4 additions & 1 deletion packages/rest/src/__tests__/unit/rest.component.unit.ts
Expand Up @@ -20,6 +20,7 @@ import {
RestComponentConfig,
RestServer,
} from '../..';
import {aRestServerConfig} from '../helpers';

const SequenceActions = RestBindings.SequenceActions;
describe('RestComponent', () => {
Expand All @@ -34,7 +35,9 @@ describe('RestComponent', () => {

// Stub constructor requirements for some providers.
app.bind(RestBindings.Http.CONTEXT).to(new Context());
app.bind(RestBindings.HANDLER).to(new HttpHandler(app));
app
.bind(RestBindings.HANDLER)
.to(new HttpHandler(app, aRestServerConfig()));

comp = await app.get<Component>('components.RestComponent');
});
Expand Down
7 changes: 5 additions & 2 deletions packages/rest/src/http-handler.ts
Expand Up @@ -19,15 +19,17 @@ import {Request, Response} from './types';

import {RestBindings} from './keys';
import {RequestContext} from './request-context';
import {RestServerResolvedConfig} from './rest.server';

export class HttpHandler {
protected _apiDefinitions: SchemasObject;

public handleRequest: (request: Request, response: Response) => Promise<void>;

constructor(
protected _rootContext: Context,
protected _routes = new RoutingTable(),
protected readonly _rootContext: Context,
protected readonly _serverConfig: RestServerResolvedConfig,
protected readonly _routes = new RoutingTable(),
) {
this.handleRequest = (req, res) => this._handleRequest(req, res);
}
Expand Down Expand Up @@ -70,6 +72,7 @@ export class HttpHandler {
request,
response,
this._rootContext,
this._serverConfig,
);

const sequence = await requestContext.get<SequenceHandler>(
Expand Down
82 changes: 82 additions & 0 deletions packages/rest/src/request-context.ts
Expand Up @@ -6,17 +6,90 @@
import {Context} from '@loopback/context';
import * as onFinished from 'on-finished';
import {RestBindings} from './keys';
import {RestServerResolvedConfig} from './rest.server';
import {HandlerContext, Request, Response} from './types';

/**
* A per-request Context combining an IoC container with handler context
* (request, response, etc.).
*/
export class RequestContext extends Context implements HandlerContext {
/**
* Get the protocol used by the client to make the request.
* Please note this protocol may be different from what we are observing
* at HTTP/TCP level, because reverse proxies like nginx or sidecars like
* Envoy are switching between protocols.
*/
get requestedProtocol(): string {
return (
(this.request.get('x-forwarded-proto') || '').split(',')[0] ||
this.request.protocol ||
this.serverConfig.protocol ||
'http'
);
}

/**
* Get the effective base path of the incoming request. This base path
* combines `baseUrl` provided by Express when LB4 handler is mounted on
* a non-root path, with the `basePath` value configured at LB4 side.
*/
get basePath(): string {
const request = this.request;
let basePath = this.serverConfig.basePath || '';
if (request.baseUrl && request.baseUrl !== '/') {
basePath = request.baseUrl + basePath;
}
return basePath;
}

/**
* Get the base URL used by the client to make the request.
* This URL contains the protocol, hostname, port and base path.
* The path of the invoked route and query string is not included.
*
* Please note these values may be different from what we are observing
* at HTTP/TCP level, because reverse proxies like nginx are rewriting them.
*/
get requestedBaseUrl(): string {
const request = this.request;
const config = this.serverConfig;

const protocol = this.requestedProtocol;
// The host can be in one of the forms
// [::1]:3000
// [::1]
// 127.0.0.1:3000
// 127.0.0.1
let {host, port} = parseHostAndPort(
request.get('x-forwarded-host') || request.headers.host,
);

const forwardedPort = (request.get('x-forwarded-port') || '').split(',')[0];
port = forwardedPort || port;

if (!host) {
// No host detected from http headers
// Use the configured values or the local network address
host = config.host || request.socket.localAddress;
port = (config.port || request.socket.localPort).toString();
}

// clear default ports
port = protocol === 'https' && port === '443' ? '' : port;
port = protocol === 'http' && port === '80' ? '' : port;

// add port number of present
host += port !== '' ? ':' + port : '';

return protocol + '://' + host + this.basePath;
}

constructor(
public readonly request: Request,
public readonly response: Response,
parent: Context,
public readonly serverConfig: RestServerResolvedConfig,
name?: string,
) {
super(parent, name);
Expand All @@ -40,3 +113,12 @@ export class RequestContext extends Context implements HandlerContext {
.lock();
}
}

function parseHostAndPort(host: string | undefined) {
host = host || '';
host = host.split(',')[0];
const portPattern = /:([0-9]+)$/;
const port = (host.match(portPattern) || [])[1] || '';
host = host.replace(portPattern, '');
return {host, port};
}

0 comments on commit 912bece

Please sign in to comment.