Skip to content

Commit 1016a09

Browse files
committed
feat(rest): allow basePath for rest servers
See #918
1 parent ad905a5 commit 1016a09

File tree

7 files changed

+202
-12
lines changed

7 files changed

+202
-12
lines changed

docs/site/Server.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,38 @@ export async function main() {
211211
For a complete list of CORS options, see
212212
https://github.com/expressjs/cors#configuration-options.
213213

214+
### Configure the Base Path
215+
216+
Sometime it's desirable to expose REST endpoints using a base path, such as
217+
`/api`. The base path can be set as part of the RestServer configuration.
218+
219+
```ts
220+
const app = new RestApplication({
221+
rest: {
222+
basePath: '/api',
223+
},
224+
});
225+
```
226+
227+
The `RestApplication` and `RestServer` both provide a `basePath()` API:
228+
229+
```ts
230+
const app: RestApplication;
231+
// ...
232+
app.basePath('/api');
233+
```
234+
235+
With the `basePath`, all REST APIs and static assets are served on URLs starting
236+
with the base path.
237+
214238
### `rest` options
215239

216240
| Property | Type | Purpose |
217241
| ----------- | ------------------- | --------------------------------------------------------------------------------------------------------- |
218-
| port | number | Specify the port on which the RestServer will listen for traffic. |
219-
| protocol | string (http/https) | Specify the protocol on which the RestServer will listen for traffic. |
242+
| host | string | Specify the hostname or ip address on which the RestServer will listen for traffic. |
243+
| port | number | Specify the port on which the RestServer listens for traffic. |
244+
| protocol | string (http/https) | Specify the protocol on which the RestServer listens for traffic. |
245+
| basePath | string | Specify the base path that RestServer exposes http endpoints. |
220246
| key | string | Specify the SSL private key for https. |
221247
| cert | string | Specify the SSL certificate for https. |
222248
| cors | CorsOptions | Specify the CORS options. |

packages/rest/src/keys.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export namespace RestBindings {
6464
export const HTTPS_OPTIONS = BindingKey.create<https.ServerOptions>(
6565
'rest.httpsOptions',
6666
);
67+
68+
/**
69+
* Internal binding key for basePath
70+
*/
71+
export const BASE_PATH = BindingKey.create<string>('rest.basePath');
72+
6773
/**
6874
* Internal binding key for http-handler
6975
*/

packages/rest/src/rest.application.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ export class RestApplication extends Application implements HttpServerLike {
107107
return this.restServer.bodyParser(bodyParserClass, address);
108108
}
109109

110+
/**
111+
* Configure the `basePath` for the rest server
112+
* @param path Base path
113+
*/
114+
basePath(path: string = '') {
115+
this.restServer.basePath(path);
116+
}
117+
110118
/**
111119
* Register a new Controller-based route.
112120
*

packages/rest/src/rest.server.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,18 @@ export class RestServer extends Context implements Server, HttpServerLike {
123123
* @param req The request.
124124
* @param res The response.
125125
*/
126-
public requestHandler: HttpRequestListener;
126+
127+
protected _requestHandler: HttpRequestListener;
128+
public get requestHandler(): HttpRequestListener {
129+
if (this._requestHandler == null) {
130+
this._setupRequestHandlerIfNeeded();
131+
}
132+
return this._requestHandler;
133+
}
127134

128135
public readonly config: RestServerConfig;
136+
private _basePath: string;
137+
129138
protected _httpHandler: HttpHandler;
130139
protected get httpHandler(): HttpHandler {
131140
this._setupHandlerIfNeeded();
@@ -185,15 +194,17 @@ export class RestServer extends Context implements Server, HttpServerLike {
185194
this.sequence(config.sequence);
186195
}
187196

188-
this._setupRequestHandler();
197+
this.basePath(config.basePath);
189198

199+
this.bind(RestBindings.BASE_PATH).toDynamicValue(() => this._basePath);
190200
this.bind(RestBindings.HANDLER).toDynamicValue(() => this.httpHandler);
191201
}
192202

193-
protected _setupRequestHandler() {
203+
protected _setupRequestHandlerIfNeeded() {
204+
if (this._expressApp) return;
194205
this._expressApp = express();
195206
this._expressApp.set('query parser', 'extended');
196-
this.requestHandler = this._expressApp;
207+
this._requestHandler = this._expressApp;
197208

198209
// Allow CORS support for all endpoints so that users
199210
// can test with online SwaggerUI instance
@@ -211,7 +222,7 @@ export class RestServer extends Context implements Server, HttpServerLike {
211222
this._setupOpenApiSpecEndpoints();
212223

213224
// Mount our router & request handler
214-
this._expressApp.use((req, res, next) => {
225+
this._expressApp.use(this._basePath, (req, res, next) => {
215226
this._handleHttpRequest(req, res).catch(next);
216227
});
217228

@@ -365,6 +376,15 @@ export class RestServer extends Context implements Server, HttpServerLike {
365376
specObj.servers = [{url: this._getUrlForClient(request)}];
366377
}
367378

379+
if (specObj.servers && this._basePath) {
380+
for (const s of specObj.servers) {
381+
// Update the default server url to honor `basePath`
382+
if (s.url === '/') {
383+
s.url = this._basePath;
384+
}
385+
}
386+
}
387+
368388
if (specForm.format === 'json') {
369389
const spec = JSON.stringify(specObj, null, 2);
370390
response.setHeader('content-type', 'application/json; charset=utf-8');
@@ -433,7 +453,7 @@ export class RestServer extends Context implements Server, HttpServerLike {
433453
// add port number of present
434454
host += port !== '' ? ':' + port : '';
435455

436-
return protocol + '://' + host;
456+
return protocol + '://' + host + this._basePath;
437457
}
438458

439459
private async _redirectToSwaggerUI(
@@ -732,13 +752,31 @@ export class RestServer extends Context implements Server, HttpServerLike {
732752
return binding;
733753
}
734754

755+
/**
756+
* Configure the `basePath` for the rest server
757+
* @param path Base path
758+
*/
759+
basePath(path: string = '') {
760+
if (this._requestHandler) {
761+
throw new Error(
762+
'Base path cannot be set as the request handler has been created',
763+
);
764+
}
765+
// Trim leading and trailing `/`
766+
path = path.replace(/(^\/)|(\/$)/, '');
767+
if (path) path = '/' + path;
768+
this._basePath = path;
769+
}
770+
735771
/**
736772
* Start this REST API's HTTP/HTTPS server.
737773
*
738774
* @returns {Promise<void>}
739775
* @memberof RestServer
740776
*/
741777
async start(): Promise<void> {
778+
// Set up the Express app if not done yet
779+
this._setupRequestHandlerIfNeeded();
742780
// Setup the HTTP handler so that we can verify the configuration
743781
// of API spec, controllers and routes at startup time.
744782
this._setupHandlerIfNeeded();
@@ -875,6 +913,10 @@ export interface ApiExplorerOptions {
875913
* Options for RestServer configuration
876914
*/
877915
export interface RestServerOptions {
916+
/**
917+
* Base path for API/static routes
918+
*/
919+
basePath?: string;
878920
cors?: cors.CorsOptions;
879921
openApiSpec?: OpenApiSpecOptions;
880922
apiExplorer?: ApiExplorerOptions;

packages/rest/test/integration/rest.application.integration.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {createRestAppClient, Client, expect} from '@loopback/testlab';
7-
import {RestApplication} from '../..';
8-
import * as path from 'path';
6+
import {anOperationSpec} from '@loopback/openapi-spec-builder';
7+
import {Client, createRestAppClient, expect} from '@loopback/testlab';
98
import * as fs from 'fs';
10-
import {RestServer, RestServerConfig} from '../../src';
9+
import * as path from 'path';
10+
import {RestApplication, RestServer, RestServerConfig} from '../..';
1111

1212
const ASSETS = path.resolve(__dirname, '../../../fixtures/assets');
1313

@@ -92,6 +92,27 @@ describe('RestApplication (integration)', () => {
9292
.expect('Hello');
9393
});
9494

95+
it('honors basePath for static assets', async () => {
96+
givenApplication();
97+
restApp.basePath('/html');
98+
restApp.static('/', ASSETS);
99+
await restApp.start();
100+
client = createRestAppClient(restApp);
101+
await client.get('/html/index.html').expect(200);
102+
});
103+
104+
it('honors basePath for routes', async () => {
105+
givenApplication();
106+
restApp.basePath('/api');
107+
restApp.route('get', '/status', anOperationSpec().build(), () => ({
108+
running: true,
109+
}));
110+
111+
await restApp.start();
112+
client = createRestAppClient(restApp);
113+
await client.get('/api/status').expect(200, {running: true});
114+
});
115+
95116
it('returns RestServer instance', async () => {
96117
givenApplication();
97118
const restServer = restApp.restServer;

packages/rest/test/integration/rest.server.integration.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,56 @@ paths:
687687
await server.stop();
688688
});
689689

690+
describe('basePath', () => {
691+
const root = ASSETS;
692+
let server: RestServer;
693+
694+
beforeEach(async () => {
695+
server = await givenAServer({
696+
rest: {
697+
basePath: '/api',
698+
port: 0,
699+
},
700+
});
701+
});
702+
703+
it('controls static assets', async () => {
704+
server.static('/html', root);
705+
706+
const content = fs
707+
.readFileSync(path.join(root, 'index.html'))
708+
.toString('utf-8');
709+
await createClientForHandler(server.requestHandler)
710+
.get('/api/html/index.html')
711+
.expect('Content-Type', /text\/html/)
712+
.expect(200, content);
713+
});
714+
715+
it('controls controller routes', async () => {
716+
server.controller(DummyController);
717+
718+
await createClientForHandler(server.requestHandler)
719+
.get('/api/html')
720+
.expect(200, 'Hi');
721+
});
722+
723+
it('reports 404 if not found', async () => {
724+
server.static('/html', root);
725+
server.controller(DummyController);
726+
727+
await createClientForHandler(server.requestHandler)
728+
.get('/html')
729+
.expect(404);
730+
});
731+
732+
it('controls server urls', async () => {
733+
const response = await createClientForHandler(server.requestHandler).get(
734+
'/openapi.json',
735+
);
736+
expect(response.body.servers).to.containEql({url: '/api'});
737+
});
738+
});
739+
690740
async function givenAServer(
691741
options: {rest: RestServerConfig} = {rest: {port: 0}},
692742
) {

packages/rest/test/unit/rest.server/rest.server.unit.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,43 @@ describe('RestServer', () => {
8686
expect(server.getSync(RestBindings.PORT)).to.equal(4000);
8787
expect(server.getSync(RestBindings.HOST)).to.equal('my-host');
8888
});
89+
90+
it('honors basePath in config', async () => {
91+
const app = new Application({
92+
rest: {port: 0, basePath: '/api'},
93+
});
94+
app.component(RestComponent);
95+
const server = await app.getServer(RestServer);
96+
expect(server.getSync(RestBindings.BASE_PATH)).to.equal('/api');
97+
});
98+
99+
it('honors basePath via api', async () => {
100+
const app = new Application({
101+
rest: {port: 0},
102+
});
103+
app.component(RestComponent);
104+
const server = await app.getServer(RestServer);
105+
server.basePath('/api');
106+
expect(server.getSync(RestBindings.BASE_PATH)).to.equal('/api');
107+
});
108+
109+
it('rejects basePath if request handler is created', async () => {
110+
const app = new Application({
111+
rest: {port: 0},
112+
});
113+
app.component(RestComponent);
114+
const server = await app.getServer(RestServer);
115+
expect(() => {
116+
// Force the `getter` function to be triggered by referencing
117+
// `server.requestHandler` so that the servers has `requestHandler`
118+
// populated to prevent `basePath` to be set.
119+
if (server.requestHandler) {
120+
server.basePath('/api');
121+
}
122+
}).to.throw(
123+
/Base path cannot be set as the request handler has been created/,
124+
);
125+
});
89126
});
90127

91128
async function givenRequestContext() {

0 commit comments

Comments
 (0)