From 1b99be4d5f8ad02a2f2d58724f4f2be7a5c66c61 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 29 Nov 2018 14:21:03 -0800 Subject: [PATCH] feat(rest): allow basePath for rest servers See https://github.com/strongloop/loopback-next/issues/918 --- docs/site/Server.md | 30 ++++++++++- packages/rest/src/keys.ts | 6 +++ packages/rest/src/rest.application.ts | 8 +++ packages/rest/src/rest.server.ts | 54 ++++++++++++++++--- .../rest.application.integration.ts | 29 ++++++++-- .../integration/rest.server.integration.ts | 50 +++++++++++++++++ .../test/unit/rest.server/rest.server.unit.ts | 37 +++++++++++++ 7 files changed, 202 insertions(+), 12 deletions(-) diff --git a/docs/site/Server.md b/docs/site/Server.md index 58c264a5119b..39e037e2b170 100644 --- a/docs/site/Server.md +++ b/docs/site/Server.md @@ -211,12 +211,38 @@ export async function main() { For a complete list of CORS options, see https://github.com/expressjs/cors#configuration-options. +### Configure the Base Path + +Sometime it's desirable to expose REST endpoints using a base path, such as +`/api`. The base path can be set as part of the RestServer configuration. + +```ts +const app = new RestApplication({ + rest: { + basePath: '/api', + }, +}); +``` + +The `RestApplication` and `RestServer` both provide a `basePath()` API: + +```ts +const app: RestApplication; +// ... +app.basePath('/api'); +``` + +With the `basePath`, all REST APIs and static assets are served on URLs starting +with the base path. + ### `rest` options | Property | Type | Purpose | | ----------- | ------------------- | --------------------------------------------------------------------------------------------------------- | -| port | number | Specify the port on which the RestServer will listen for traffic. | -| protocol | string (http/https) | Specify the protocol on which the RestServer will listen for traffic. | +| host | string | Specify the hostname or ip address on which the RestServer will listen for traffic. | +| port | number | Specify the port on which the RestServer listens for traffic. | +| protocol | string (http/https) | Specify the protocol on which the RestServer listens for traffic. | +| basePath | string | Specify the base path that RestServer exposes http endpoints. | | key | string | Specify the SSL private key for https. | | cert | string | Specify the SSL certificate for https. | | cors | CorsOptions | Specify the CORS options. | diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index 1319b350d07a..3a35b3461173 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -64,6 +64,12 @@ export namespace RestBindings { export const HTTPS_OPTIONS = BindingKey.create( 'rest.httpsOptions', ); + + /** + * Internal binding key for basePath + */ + export const BASE_PATH = BindingKey.create('rest.basePath'); + /** * Internal binding key for http-handler */ diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index fe4e2b57918c..d256158c3550 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -107,6 +107,14 @@ export class RestApplication extends Application implements HttpServerLike { return this.restServer.bodyParser(bodyParserClass, address); } + /** + * Configure the `basePath` for the rest server + * @param path Base path + */ + basePath(path: string = '') { + this.restServer.basePath(path); + } + /** * Register a new Controller-based route. * diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 2f41c923560f..f83bb747d596 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -123,9 +123,18 @@ export class RestServer extends Context implements Server, HttpServerLike { * @param req The request. * @param res The response. */ - public requestHandler: HttpRequestListener; + + protected _requestHandler: HttpRequestListener; + public get requestHandler(): HttpRequestListener { + if (this._requestHandler == null) { + this._setupRequestHandlerIfNeeded(); + } + return this._requestHandler; + } public readonly config: RestServerConfig; + private _basePath: string; + protected _httpHandler: HttpHandler; protected get httpHandler(): HttpHandler { this._setupHandlerIfNeeded(); @@ -185,15 +194,17 @@ export class RestServer extends Context implements Server, HttpServerLike { this.sequence(config.sequence); } - this._setupRequestHandler(); + this.basePath(config.basePath); + this.bind(RestBindings.BASE_PATH).toDynamicValue(() => this._basePath); this.bind(RestBindings.HANDLER).toDynamicValue(() => this.httpHandler); } - protected _setupRequestHandler() { + protected _setupRequestHandlerIfNeeded() { + if (this._expressApp) return; this._expressApp = express(); this._expressApp.set('query parser', 'extended'); - this.requestHandler = this._expressApp; + this._requestHandler = this._expressApp; // Allow CORS support for all endpoints so that users // can test with online SwaggerUI instance @@ -211,7 +222,7 @@ export class RestServer extends Context implements Server, HttpServerLike { this._setupOpenApiSpecEndpoints(); // Mount our router & request handler - this._expressApp.use((req, res, next) => { + this._expressApp.use(this._basePath, (req, res, next) => { this._handleHttpRequest(req, res).catch(next); }); @@ -365,6 +376,15 @@ export class RestServer extends Context implements Server, HttpServerLike { specObj.servers = [{url: this._getUrlForClient(request)}]; } + if (specObj.servers && this._basePath) { + for (const s of specObj.servers) { + // Update the default server url to honor `basePath` + if (s.url === '/') { + s.url = this._basePath; + } + } + } + if (specForm.format === 'json') { const spec = JSON.stringify(specObj, null, 2); response.setHeader('content-type', 'application/json; charset=utf-8'); @@ -433,7 +453,7 @@ export class RestServer extends Context implements Server, HttpServerLike { // add port number of present host += port !== '' ? ':' + port : ''; - return protocol + '://' + host; + return protocol + '://' + host + this._basePath; } private async _redirectToSwaggerUI( @@ -732,6 +752,22 @@ export class RestServer extends Context implements Server, HttpServerLike { return binding; } + /** + * Configure the `basePath` for the rest server + * @param path Base path + */ + basePath(path: string = '') { + if (this._requestHandler) { + throw new Error( + 'Base path cannot be set as the request handler has been created', + ); + } + // Trim leading and trailing `/` + path = path.replace(/(^\/)|(\/$)/, ''); + if (path) path = '/' + path; + this._basePath = path; + } + /** * Start this REST API's HTTP/HTTPS server. * @@ -739,6 +775,8 @@ export class RestServer extends Context implements Server, HttpServerLike { * @memberof RestServer */ async start(): Promise { + // Set up the Express app if not done yet + this._setupRequestHandlerIfNeeded(); // Setup the HTTP handler so that we can verify the configuration // of API spec, controllers and routes at startup time. this._setupHandlerIfNeeded(); @@ -875,6 +913,10 @@ export interface ApiExplorerOptions { * Options for RestServer configuration */ export interface RestServerOptions { + /** + * Base path for API/static routes + */ + basePath?: string; cors?: cors.CorsOptions; openApiSpec?: OpenApiSpecOptions; apiExplorer?: ApiExplorerOptions; diff --git a/packages/rest/test/integration/rest.application.integration.ts b/packages/rest/test/integration/rest.application.integration.ts index cf8578b8f6d5..1521dae2ba5a 100644 --- a/packages/rest/test/integration/rest.application.integration.ts +++ b/packages/rest/test/integration/rest.application.integration.ts @@ -3,11 +3,11 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {createRestAppClient, Client, expect} from '@loopback/testlab'; -import {RestApplication} from '../..'; -import * as path from 'path'; +import {anOperationSpec} from '@loopback/openapi-spec-builder'; +import {Client, createRestAppClient, expect} from '@loopback/testlab'; import * as fs from 'fs'; -import {RestServer, RestServerConfig} from '../../src'; +import * as path from 'path'; +import {RestApplication, RestServer, RestServerConfig} from '../..'; const ASSETS = path.resolve(__dirname, '../../../fixtures/assets'); @@ -92,6 +92,27 @@ describe('RestApplication (integration)', () => { .expect('Hello'); }); + it('honors basePath for static assets', async () => { + givenApplication(); + restApp.basePath('/html'); + restApp.static('/', ASSETS); + await restApp.start(); + client = createRestAppClient(restApp); + await client.get('/html/index.html').expect(200); + }); + + it('honors basePath for routes', async () => { + givenApplication(); + restApp.basePath('/api'); + restApp.route('get', '/status', anOperationSpec().build(), () => ({ + running: true, + })); + + await restApp.start(); + client = createRestAppClient(restApp); + await client.get('/api/status').expect(200, {running: true}); + }); + it('returns RestServer instance', async () => { givenApplication(); const restServer = restApp.restServer; diff --git a/packages/rest/test/integration/rest.server.integration.ts b/packages/rest/test/integration/rest.server.integration.ts index 94c71a2c7e27..ab1e96002276 100644 --- a/packages/rest/test/integration/rest.server.integration.ts +++ b/packages/rest/test/integration/rest.server.integration.ts @@ -687,6 +687,56 @@ paths: await server.stop(); }); + describe('basePath', () => { + const root = ASSETS; + let server: RestServer; + + beforeEach(async () => { + server = await givenAServer({ + rest: { + basePath: '/api', + port: 0, + }, + }); + }); + + it('controls static assets', async () => { + server.static('/html', root); + + const content = fs + .readFileSync(path.join(root, 'index.html')) + .toString('utf-8'); + await createClientForHandler(server.requestHandler) + .get('/api/html/index.html') + .expect('Content-Type', /text\/html/) + .expect(200, content); + }); + + it('controls controller routes', async () => { + server.controller(DummyController); + + await createClientForHandler(server.requestHandler) + .get('/api/html') + .expect(200, 'Hi'); + }); + + it('reports 404 if not found', async () => { + server.static('/html', root); + server.controller(DummyController); + + await createClientForHandler(server.requestHandler) + .get('/html') + .expect(404); + }); + + it('controls server urls', async () => { + const response = await createClientForHandler(server.requestHandler).get( + '/openapi.json', + ); + expect(response.body.servers).to.containEql({url: '/api'}); + }); + }); + async function givenAServer( options: {rest: RestServerConfig} = {rest: {port: 0}}, ) { diff --git a/packages/rest/test/unit/rest.server/rest.server.unit.ts b/packages/rest/test/unit/rest.server/rest.server.unit.ts index ba7e6c6e7798..3b8122c74fac 100644 --- a/packages/rest/test/unit/rest.server/rest.server.unit.ts +++ b/packages/rest/test/unit/rest.server/rest.server.unit.ts @@ -86,6 +86,43 @@ describe('RestServer', () => { expect(server.getSync(RestBindings.PORT)).to.equal(4000); expect(server.getSync(RestBindings.HOST)).to.equal('my-host'); }); + + it('honors basePath in config', async () => { + const app = new Application({ + rest: {port: 0, basePath: '/api'}, + }); + app.component(RestComponent); + const server = await app.getServer(RestServer); + expect(server.getSync(RestBindings.BASE_PATH)).to.equal('/api'); + }); + + it('honors basePath via api', async () => { + const app = new Application({ + rest: {port: 0}, + }); + app.component(RestComponent); + const server = await app.getServer(RestServer); + server.basePath('/api'); + expect(server.getSync(RestBindings.BASE_PATH)).to.equal('/api'); + }); + + it('rejects basePath if request handler is created', async () => { + const app = new Application({ + rest: {port: 0}, + }); + app.component(RestComponent); + const server = await app.getServer(RestServer); + expect(() => { + // Force the `getter` function to be triggered by referencing + // `server.requestHandler` so that the servers has `requestHandler` + // populated to prevent `basePath` to be set. + if (server.requestHandler) { + server.basePath('/api'); + } + }).to.throw( + /Base path cannot be set as the request handler has been created/, + ); + }); }); async function givenRequestContext() {