diff --git a/examples/express-composition/src/__tests__/acceptance/express.acceptance.ts b/examples/express-composition/src/__tests__/acceptance/express.acceptance.ts
index a057bbb17fbb..8c27fff36488 100644
--- a/examples/express-composition/src/__tests__/acceptance/express.acceptance.ts
+++ b/examples/express-composition/src/__tests__/acceptance/express.acceptance.ts
@@ -45,7 +45,9 @@ describe('ExpressApplication', () => {
await client
.get('/api/explorer')
.expect(301)
- .expect('location', '/api/explorer/');
+ // expect relative redirect so that it works seamlessly with many forms
+ // of base path, whether within the app or applied by a reverse proxy
+ .expect('location', './explorer/');
});
it('displays explorer page', async () => {
@@ -53,7 +55,7 @@ describe('ExpressApplication', () => {
.get('/api/explorer/')
.expect(200)
.expect('content-type', /html/)
- .expect(/url\: '\/api\/openapi\.json'\,/)
+ .expect(/url\: '\.\/openapi\.json'\,/)
.expect(/
LoopBack API Explorer/);
});
});
diff --git a/examples/express-composition/src/application.ts b/examples/express-composition/src/application.ts
index 6ea6856082c4..d8635568cfba 100644
--- a/examples/express-composition/src/application.ts
+++ b/examples/express-composition/src/application.ts
@@ -27,10 +27,6 @@ export class NoteApplication extends BootMixin(
// Set up default home page
this.static('/', path.join(__dirname, '../public'));
- // Customize @loopback/rest-explorer configuration here
- this.bind(RestExplorerBindings.CONFIG).to({
- path: '/explorer',
- });
this.component(RestExplorerComponent);
this.projectRoot = __dirname;
diff --git a/packages/rest-explorer/README.md b/packages/rest-explorer/README.md
index 9618344e99f4..879986d448c8 100644
--- a/packages/rest-explorer/README.md
+++ b/packages/rest-explorer/README.md
@@ -52,6 +52,77 @@ requesting a configuration option for customizing the visual style, please
up-vote the issue and/or join the discussion if you are interested in this
feature._
+### Advanced Configuration and Reverse Proxies
+
+By default, the component will add an additional OpenAPI spec endpoint, in the
+format it needs, at a fixed relative path to that of the Explorer itself. For
+example, in the default configuration, it will expose `/explorer/openapi.json`,
+or in the examples above with the Explorer path configured, it would expose
+`/openapi/ui/openapi.json`. This is to allow it to use a fixed relative path to
+load the spec, to be tolerant of running behind reverse proxies.
+
+You may turn off this behavior in the component configuration, for example:
+
+```ts
+this.configure(RestExplorerBindings.COMPONENT).to({
+ useSelfHostedSpec: false,
+});
+```
+
+If you do so, it will try to locate an existing configured OpenAPI spec endpoint
+of the required form in the REST Server configuration. This may be problematic
+when operating behind a reverse proxy that inserts a path prefix.
+
+When operating behind a reverse proxy that does path changes, such as inserting
+a prefix on the path, using the default behavior for `useSelfHostedSpec` is the
+simplest option, but is not sufficient to have a functioning Explorer. You will
+also need to explicitly configure `rest.openApiSpec.servers` (in your
+application configuration object) to have an entry that has the correct host and
+path as seen by the _client_ browser.
+
+Note that in this scenario, setting `rest.openApiSpec.setServersFromRequest` is
+not recommended, as it will cause the path information to be lost, as the
+standards for HTTP reverse proxies only provide means to tell the proxied server
+(your app) about the _hostname_ used for the original request, not the full
+original _path_.
+
+Note also that you cannot use a url-relative path for the `servers` entry, as
+the Swagger UI does not support that (yet). You may use a _host_-relative path
+however.
+
+#### Summary
+
+For some common scenarios, here are recommended configurations to have the
+explorer working properly. Note that these are not the _only_ configurations
+that will work reliably, they are just the _simplest_ ones to setup.
+
+| Scenario | `useSelfHostedSpec` | `setServersFromRequest` | `servers` |
+| ----------------------------------------------------------------------------------- | ------------------- | -------------------------------------- | ---------------------------------------------------------------- |
+| App exposed directly | yes | either | automatic |
+| App behind simple reverse proxy | yes | yes | automatic |
+| App exposed directly or behind simple proxy, with a `basePath` set | yes | yes | automatic |
+| App exposed directly or behind simple proxy, mounted inside another express app | yes | yes | automatic |
+| App behind path-modifying reverse proxy, modifications known to app1 | yes | no | configure manually as host-relative path, as clients will see it |
+| App behind path-modifying reverse proxy, modifications not known to app2 | ? | ? | ? |
+| App uses custom OpenAPI spec instead of LB4-generated one | no | depends on reverse-proxy configuration | depends on reverse-proxy configuration |
+
+1 The modifications need to be known to the app at build or startup
+time so that you can manually configure the `servers` list. For example, if you
+know that your reverse proxy is going to expose the root of your app at
+`/foo/bar/`, then you would set the first of your `servers` entries to
+`/foo/bar`. This scenario also cases where the app is using a `basePath` or is
+mounted inside another express app, with this same reverse proxy setup. In those
+cases the manually configured `servers` entry will need to account for the path
+prefixes the `basePath` or express embedding adds in addition to what the
+reverse proxy does.
+
+2 Due to limitations in the OpenAPI spec and what information is
+provided by the reverse proxy to the app, this is a scenario without a clear
+standards-based means of getting a working explorer. A custom solution would be
+needed in this situation, such as passing a non-standard header from your
+reverse proxy to tell the app the external path, and custom code in your app to
+make the app and explorer aware of this.
+
## Contributions
- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md)
diff --git a/packages/rest-explorer/src/__tests__/acceptance/rest-explorer.acceptance.ts b/packages/rest-explorer/src/__tests__/acceptance/rest-explorer.acceptance.ts
index 0982625993fa..be9c5979953c 100644
--- a/packages/rest-explorer/src/__tests__/acceptance/rest-explorer.acceptance.ts
+++ b/packages/rest-explorer/src/__tests__/acceptance/rest-explorer.acceptance.ts
@@ -45,13 +45,19 @@ describe('API Explorer (acceptance)', () => {
await request
.get('/explorer')
.expect(301)
- .expect('location', '/explorer/');
+ // expect relative redirect so that it works seamlessly with many forms
+ // of base path, whether within the app or applied by a reverse proxy
+ .expect('location', './explorer/');
});
- it('configures swagger-ui with OpenAPI spec url "/openapi.json', async () => {
+ it('configures swagger-ui with OpenAPI spec url "./openapi.json', async () => {
const response = await request.get('/explorer/').expect(200);
const body = response.body;
- expect(body).to.match(/^\s*url: '\/openapi.json',\s*$/m);
+ expect(body).to.match(/^\s*url: '\.\/openapi.json',\s*$/m);
+ });
+
+ it('hosts OpenAPI at "./openapi.json', async () => {
+ await request.get('/explorer/openapi.json').expect(200);
});
it('mounts swagger-ui assets at "/explorer"', async () => {
@@ -61,8 +67,8 @@ describe('API Explorer (acceptance)', () => {
});
context('with custom RestServerConfig', () => {
- it('honours custom OpenAPI path', async () => {
- await givenAppWithCustomRestConfig({
+ it('uses self-hosted spec by default', async () => {
+ await givenAppWithCustomExplorerConfig({
openApiSpec: {
endpointMapping: {
'/apispec': {format: 'json', version: '3.0.0'},
@@ -74,20 +80,34 @@ describe('API Explorer (acceptance)', () => {
const response = await request.get('/explorer/').expect(200);
const body = response.body;
- expect(body).to.match(/^\s*url: '\/apispec',\s*$/m);
+ expect(body).to.match(/^\s*url: '\.\/openapi.json',\s*$/m);
});
- async function givenAppWithCustomRestConfig(config: RestServerConfig) {
- app = givenRestApplication(config);
- app.component(RestExplorerComponent);
- await app.start();
- request = createRestAppClient(app);
- }
+ it('honors flag to disable self-hosted spec', async () => {
+ await givenAppWithCustomExplorerConfig(
+ {
+ openApiSpec: {
+ endpointMapping: {
+ '/apispec': {format: 'json', version: '3.0.0'},
+ '/apispec/v2': {format: 'json', version: '2.0.0'},
+ '/apispec/yaml': {format: 'yaml', version: '3.0.0'},
+ },
+ },
+ },
+ {
+ useSelfHostedSpec: false,
+ },
+ );
+
+ const response = await request.get('/explorer/').expect(200);
+ const body = response.body;
+ expect(body).to.match(/^\s*url: '\/apispec',\s*$/m);
+ });
});
context('with custom RestExplorerConfig', () => {
it('honors custom explorer path', async () => {
- await givenAppWithCustomExplorerConfig({
+ await givenAppWithCustomExplorerConfig(undefined, {
path: '/openapi/ui',
});
@@ -98,20 +118,35 @@ describe('API Explorer (acceptance)', () => {
await request
.get('/openapi/ui')
.expect(301)
- .expect('Location', '/openapi/ui/');
+ // expect relative redirect so that it works seamlessly with many forms
+ // of base path, whether within the app or applied by a reverse proxy
+ .expect('Location', './ui/');
await request.get('/explorer').expect(404);
});
- async function givenAppWithCustomExplorerConfig(
- config: RestExplorerConfig,
- ) {
- app = givenRestApplication();
- app.configure(RestExplorerBindings.COMPONENT).to(config);
- app.component(RestExplorerComponent);
- await app.start();
- request = createRestAppClient(app);
- }
+ it('honors flag to disable self-hosted spec', async () => {
+ await givenAppWithCustomExplorerConfig(undefined, {
+ path: '/openapi/ui',
+ useSelfHostedSpec: false,
+ });
+
+ const response = await request.get('/openapi/ui/').expect(200);
+ const body = response.body;
+ expect(body).to.match(/LoopBack API Explorer/);
+ expect(body).to.match(/^\s*url: '\/openapi.json',\s*$/m);
+
+ await request
+ .get('/openapi/ui')
+ .expect(301)
+ // expect relative redirect so that it works seamlessly with many forms
+ // of base path, whether within the app or applied by a reverse proxy
+ .expect('Location', './ui/');
+
+ await request.get('/explorer').expect(404);
+ await request.get('/explorer/openapi.json').expect(404);
+ await request.get('/openapi/ui/openapi.json').expect(404);
+ });
});
context('with custom basePath', () => {
@@ -130,7 +165,7 @@ describe('API Explorer (acceptance)', () => {
.expect(200)
.expect('content-type', /html/)
// OpenAPI endpoints DO NOT honor basePath
- .expect(/url\: '\/openapi\.json'\,/);
+ .expect(/url\: '\.\/openapi\.json'\,/);
});
});
@@ -138,4 +173,17 @@ describe('API Explorer (acceptance)', () => {
const rest = Object.assign({}, givenHttpServerConfig(), config);
return new RestApplication({rest});
}
+
+ async function givenAppWithCustomExplorerConfig(
+ config?: RestServerConfig,
+ explorerConfig?: RestExplorerConfig,
+ ) {
+ app = givenRestApplication(config);
+ if (explorerConfig) {
+ app.bind(RestExplorerBindings.CONFIG).to(explorerConfig);
+ }
+ app.component(RestExplorerComponent);
+ await app.start();
+ request = createRestAppClient(app);
+ }
});
diff --git a/packages/rest-explorer/src/__tests__/acceptance/rest-explorer.express.acceptance.ts b/packages/rest-explorer/src/__tests__/acceptance/rest-explorer.express.acceptance.ts
index 31c8d4df8e97..a3cf440ef08f 100644
--- a/packages/rest-explorer/src/__tests__/acceptance/rest-explorer.express.acceptance.ts
+++ b/packages/rest-explorer/src/__tests__/acceptance/rest-explorer.express.acceptance.ts
@@ -10,47 +10,90 @@ import {
givenHttpServerConfig,
} from '@loopback/testlab';
import * as express from 'express';
-import {RestExplorerComponent} from '../..';
+import {
+ RestExplorerBindings,
+ RestExplorerComponent,
+ RestExplorerConfig,
+} from '../..';
describe('REST Explorer mounted as an express router', () => {
let client: Client;
let expressApp: express.Application;
let server: RestServer;
- beforeEach(givenLoopBackApp);
- beforeEach(givenExpressApp);
- beforeEach(givenClient);
+ context('default explorer config', () => {
+ beforeEach(givenLoopBackApp);
+ beforeEach(givenExpressApp);
+ beforeEach(givenClient);
- it('exposes API Explorer at "/api/explorer/"', async () => {
- await client
- .get('/api/explorer/')
- .expect(200)
- .expect('content-type', /html/)
- .expect(/url\: '\/api\/openapi\.json'\,/);
- });
+ it('exposes API Explorer at "/api/explorer/"', async () => {
+ await client
+ .get('/api/explorer/')
+ .expect(200)
+ .expect('content-type', /html/)
+ .expect(/url\: '\.\/openapi\.json'\,/);
+ });
- it('redirects from "/api/explorer" to "/api/explorer/"', async () => {
- await client
- .get('/api/explorer')
- .expect(301)
- .expect('location', '/api/explorer/');
+ it('redirects from "/api/explorer" to "/api/explorer/"', async () => {
+ await client
+ .get('/api/explorer')
+ .expect(301)
+ // expect relative redirect so that it works seamlessly with many forms
+ // of base path, whether within the app or applied by a reverse proxy
+ .expect('location', './explorer/');
+ });
+
+ it('uses correct URLs when basePath is set', async () => {
+ server.basePath('/v1');
+ await client
+ // static assets (including swagger-ui) honor basePath
+ .get('/api/v1/explorer/')
+ .expect(200)
+ .expect('content-type', /html/)
+ // OpenAPI endpoints DO NOT honor basePath
+ .expect(/url\: '\.\/openapi\.json'\,/);
+ });
});
- it('uses correct URLs when basePath is set', async () => {
- server.basePath('/v1');
- await client
- // static assets (including swagger-ui) honor basePath
- .get('/api/v1/explorer/')
- .expect(200)
- .expect('content-type', /html/)
- // OpenAPI endpoints DO NOT honor basePath
- .expect(/url\: '\/api\/openapi\.json'\,/);
+ context('self hosted api disabled', () => {
+ beforeEach(givenLoopbackAppWithoutSelfHostedSpec);
+ beforeEach(givenExpressApp);
+ beforeEach(givenClient);
+
+ it('exposes API Explorer at "/api/explorer/"', async () => {
+ await client
+ .get('/api/explorer/')
+ .expect(200)
+ .expect('content-type', /html/)
+ .expect(/url\: '\/api\/openapi\.json'\,/);
+ });
+
+ it('uses correct URLs when basePath is set', async () => {
+ server.basePath('/v1');
+ await client
+ // static assets (including swagger-ui) honor basePath
+ .get('/api/v1/explorer/')
+ .expect(200)
+ .expect('content-type', /html/)
+ // OpenAPI endpoints DO NOT honor basePath
+ .expect(/url\: '\/api\/openapi\.json'\,/);
+ });
+
+ async function givenLoopbackAppWithoutSelfHostedSpec() {
+ return givenLoopBackApp(undefined, {
+ useSelfHostedSpec: false,
+ });
+ }
});
async function givenLoopBackApp(
options: {rest: RestServerConfig} = {rest: {port: 0}},
+ explorerConfig?: RestExplorerConfig,
) {
options.rest = givenHttpServerConfig(options.rest);
const app = new RestApplication(options);
+ if (explorerConfig) {
+ app.bind(RestExplorerBindings.CONFIG).to(explorerConfig);
+ }
app.component(RestExplorerComponent);
server = await app.getServer(RestServer);
}
diff --git a/packages/rest-explorer/src/rest-explorer.component.ts b/packages/rest-explorer/src/rest-explorer.component.ts
index e68c31afb04c..cc5c28a1f05f 100644
--- a/packages/rest-explorer/src/rest-explorer.component.ts
+++ b/packages/rest-explorer/src/rest-explorer.component.ts
@@ -28,6 +28,13 @@ export class RestExplorerComponent implements Component {
this.registerControllerRoute('get', explorerPath, 'indexRedirect');
this.registerControllerRoute('get', explorerPath + '/', 'index');
+ if (restExplorerConfig.useSelfHostedSpec !== false) {
+ this.registerControllerRoute(
+ 'get',
+ explorerPath + '/openapi.json',
+ 'spec',
+ );
+ }
application.static(explorerPath, swaggerUI.getAbsoluteFSPath());
diff --git a/packages/rest-explorer/src/rest-explorer.controller.ts b/packages/rest-explorer/src/rest-explorer.controller.ts
index acc67b893407..2e67ddffcee3 100644
--- a/packages/rest-explorer/src/rest-explorer.controller.ts
+++ b/packages/rest-explorer/src/rest-explorer.controller.ts
@@ -6,14 +6,16 @@
import {inject} from '@loopback/context';
import {
OpenApiSpecForm,
- Request,
- Response,
+ RequestContext,
RestBindings,
+ RestServer,
RestServerConfig,
} from '@loopback/rest';
import * as ejs from 'ejs';
import * as fs from 'fs';
import * as path from 'path';
+import {RestExplorerBindings} from './rest-explorer.keys';
+import {RestExplorerConfig} from './rest-explorer.types';
// TODO(bajtos) Allow users to customize the template
const indexHtml = path.resolve(__dirname, '../templates/index.html.ejs');
@@ -21,52 +23,80 @@ const template = fs.readFileSync(indexHtml, 'utf-8');
const templateFn = ejs.compile(template);
export class ExplorerController {
+ static readonly OPENAPI_RELATIVE_URL = 'openapi.json';
+ static readonly OPENAPI_FORM: OpenApiSpecForm = Object.freeze({
+ version: '3.0.0',
+ format: 'json',
+ });
+
private openApiSpecUrl: string;
+ private useSelfHostedSpec: boolean;
constructor(
@inject(RestBindings.CONFIG, {optional: true})
restConfig: RestServerConfig = {},
+ @inject(RestExplorerBindings.CONFIG, {optional: true})
+ explorerConfig: RestExplorerConfig = {},
@inject(RestBindings.BASE_PATH) private serverBasePath: string,
- @inject(RestBindings.Http.REQUEST) private request: Request,
- @inject(RestBindings.Http.RESPONSE) private response: Response,
+ @inject(RestBindings.SERVER) private restServer: RestServer,
+ @inject(RestBindings.Http.CONTEXT) private requestContext: RequestContext,
) {
+ this.useSelfHostedSpec = explorerConfig.useSelfHostedSpec !== false;
this.openApiSpecUrl = this.getOpenApiSpecUrl(restConfig);
}
indexRedirect() {
- const url = this.request.originalUrl || this.request.url;
- this.response.redirect(301, url + '/');
+ const {request, response} = this.requestContext;
+ let url = request.originalUrl || request.url;
+ // be safe against path-modifying reverse proxies by generating the redirect
+ // as a _relative_ URL
+ const lastSlash = url.lastIndexOf('/');
+ if (lastSlash >= 0) {
+ url = './' + url.substr(lastSlash + 1) + '/';
+ }
+ response.redirect(301, url);
}
index() {
let openApiSpecUrl = this.openApiSpecUrl;
- // baseURL is composed from mountPath and basePath
- // OpenAPI endpoints ignore basePath but do honor mountPath
- let rootPath = this.request.baseUrl;
- if (
- this.serverBasePath &&
- this.serverBasePath !== '/' &&
- rootPath.endsWith(this.serverBasePath)
- ) {
- rootPath = rootPath.slice(0, -this.serverBasePath.length);
- }
+ // if using self-hosted openapi spec, then the path to use is always the
+ // exact relative path, and no base path logic needs to be applied
+ if (!this.useSelfHostedSpec) {
+ // baseURL is composed from mountPath and basePath
+ // OpenAPI endpoints ignore basePath but do honor mountPath
+ let rootPath = this.requestContext.request.baseUrl;
+ if (
+ this.serverBasePath &&
+ this.serverBasePath !== '/' &&
+ rootPath.endsWith(this.serverBasePath)
+ ) {
+ rootPath = rootPath.slice(0, -this.serverBasePath.length);
+ }
- if (rootPath && rootPath !== '/') {
- openApiSpecUrl = rootPath + openApiSpecUrl;
+ if (rootPath && rootPath !== '/') {
+ openApiSpecUrl = rootPath + openApiSpecUrl;
+ }
}
const data = {
openApiSpecUrl,
};
const homePage = templateFn(data);
- this.response
+ this.requestContext.response
.status(200)
.contentType('text/html')
.send(homePage);
}
+ spec() {
+ return this.restServer.getApiSpec(this.requestContext);
+ }
+
private getOpenApiSpecUrl(restConfig: RestServerConfig): string {
+ if (this.useSelfHostedSpec) {
+ return './' + ExplorerController.OPENAPI_RELATIVE_URL;
+ }
const openApiConfig = restConfig.openApiSpec || {};
const endpointMapping = openApiConfig.endpointMapping || {};
const endpoint = Object.keys(endpointMapping).find(k =>
@@ -77,5 +107,8 @@ export class ExplorerController {
}
function isOpenApiV3Json(mapping: OpenApiSpecForm) {
- return mapping.version === '3.0.0' && mapping.format === 'json';
+ return (
+ mapping.version === ExplorerController.OPENAPI_FORM.version &&
+ mapping.format === ExplorerController.OPENAPI_FORM.format
+ );
}
diff --git a/packages/rest-explorer/src/rest-explorer.types.ts b/packages/rest-explorer/src/rest-explorer.types.ts
index 67f85e808c06..f150d6b19c40 100644
--- a/packages/rest-explorer/src/rest-explorer.types.ts
+++ b/packages/rest-explorer/src/rest-explorer.types.ts
@@ -11,4 +11,22 @@ export type RestExplorerConfig = {
* URL path where to expose the explorer UI. Default: '/explorer'
*/
path?: string;
+
+ /**
+ * By default, the explorer will add an additional copy of the OpenAPI spec
+ * in v3/JSON format at a fixed url relative to the explorer itself. This
+ * simplifies making the explorer work in environments where there may be
+ * e.g. non-trivial URL rewriting done by a reverse proxy, at the expense
+ * of adding an additional endpoint to the application. You may shut off
+ * this behavior by setting this flag `false`, in which case the explorer
+ * will try to locate an OpenAPI endpoint from the RestServer that is
+ * already in the correct form.
+ *
+ * Note that, if you are behind such a reverse proxy, you still _must_
+ * explicitly set an `openApiSpecOptions.servers` entry with an absolute path
+ * (it does not need to include the protocol, host, and port) that reflects
+ * the externally visible path, as that information is not systematically
+ * forwarded to the application behind the proxy.
+ */
+ useSelfHostedSpec?: false;
};
diff --git a/packages/rest/src/__tests__/integration/rest.server.integration.ts b/packages/rest/src/__tests__/integration/rest.server.integration.ts
index 053038e2aa2b..3ceaa5e956ed 100644
--- a/packages/rest/src/__tests__/integration/rest.server.integration.ts
+++ b/packages/rest/src/__tests__/integration/rest.server.integration.ts
@@ -506,6 +506,47 @@ paths:
await test.get('/explorer').expect(404);
});
+ it('can add openApiSpec endpoints before express initialization', async () => {
+ const server = await givenAServer();
+ server.addOpenApiSpecEndpoint('/custom-openapi.json', {
+ version: '3.0.0',
+ format: 'json',
+ });
+
+ const test = createClientForHandler(server.requestHandler);
+ await test.get('/custom-openapi.json').expect(200);
+ });
+
+ // this doesn't work: once the generic routes have been added to express to
+ // direct requests at controllers, adding OpenAPI spec routes after that
+ // no longer works in the sense that express won't ever try those routes
+ // https://github.com/strongloop/loopback-next/issues/433 will make changes
+ // that make it possible to enable this test
+ it.skip('can add openApiSpec endpoints after express initialization', async () => {
+ const server = await givenAServer();
+ const test = createClientForHandler(server.requestHandler);
+ server.addOpenApiSpecEndpoint('/custom-openapi.json', {
+ version: '3.0.0',
+ format: 'json',
+ });
+
+ await test.get('/custom-openapi.json').expect(200);
+ });
+
+ it('rejects duplicate additions of openApiSpec endpoints', async () => {
+ const server = await givenAServer();
+ server.addOpenApiSpecEndpoint('/custom-openapi.json', {
+ version: '3.0.0',
+ format: 'json',
+ });
+ expect(() =>
+ server.addOpenApiSpecEndpoint('/custom-openapi.json', {
+ version: '3.0.0',
+ format: 'yaml',
+ }),
+ ).to.throw(/already configured/);
+ });
+
it('exposes "GET /explorer" endpoint', async () => {
const app = new Application();
app.component(RestComponent);
diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts
index 1d5152888dfb..14fc522b613d 100644
--- a/packages/rest/src/keys.ts
+++ b/packages/rest/src/keys.ts
@@ -26,6 +26,7 @@ import {
Response,
Send,
} from './types';
+import {RestServer} from './rest.server';
/**
* RestServer-specific bindings
@@ -62,6 +63,11 @@ export namespace RestBindings {
'rest.httpsOptions',
);
+ /**
+ * Binding key for the server itself
+ */
+ export const SERVER = BindingKey.create('servers.RestServer');
+
/**
* Internal binding key for basePath
*/
diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts
index ad64543e0ce0..49495eaf0a60 100644
--- a/packages/rest/src/rest.server.ts
+++ b/packages/rest/src/rest.server.ts
@@ -15,6 +15,7 @@ import {Application, CoreBindings, Server} from '@loopback/core';
import {HttpServer, HttpServerOptions} from '@loopback/http-server';
import {
getControllerSpec,
+ OpenAPIObject,
OpenApiSpec,
OperationObject,
ServerObject,
@@ -261,19 +262,10 @@ export class RestServer extends Context implements Server, HttpServerLike {
*/
protected _setupOpenApiSpecEndpoints() {
if (this.config.openApiSpec.disabled) return;
- // NOTE(bajtos) Regular routes are handled through Sequence.
- // IMO, this built-in endpoint should not run through a Sequence,
- // because it's not part of the application API itself.
- // E.g. if the app implements access/audit logs, I don't want
- // this endpoint to trigger a log entry. If the server implements
- // content-negotiation to support XML clients, I don't want the OpenAPI
- // spec to be converted into an XML response.
const mapping = this.config.openApiSpec.endpointMapping!;
// Serving OpenAPI spec
for (const p in mapping) {
- this._expressApp.get(p, (req, res) =>
- this._serveOpenApiSpec(req, res, mapping[p]),
- );
+ this.addOpenApiSpecEndpoint(p, mapping[p]);
}
const explorerPaths = ['/swagger-ui', '/explorer'];
@@ -282,6 +274,40 @@ export class RestServer extends Context implements Server, HttpServerLike {
);
}
+ /**
+ * Add a new non-controller endpoint hosting a form of the OpenAPI spec.
+ *
+ * @param path Path at which to host the copy of the OpenAPI
+ * @param form Form that should be renedered from that path
+ */
+ addOpenApiSpecEndpoint(path: string, form: OpenApiSpecForm) {
+ if (this._expressApp) {
+ // if the app is already started, try to hot-add it
+ // this only actually "works" mid-startup, once this._handleHttpRequest
+ // has been added to express, adding any later routes won't work
+
+ // NOTE(bajtos) Regular routes are handled through Sequence.
+ // IMO, this built-in endpoint should not run through a Sequence,
+ // because it's not part of the application API itself.
+ // E.g. if the app implements access/audit logs, I don't want
+ // this endpoint to trigger a log entry. If the server implements
+ // content-negotiation to support XML clients, I don't want the OpenAPI
+ // spec to be converted into an XML response.
+ this._expressApp.get(path, (req, res) =>
+ this._serveOpenApiSpec(req, res, form),
+ );
+ } else {
+ // if the app is not started, add the mapping to the config
+ const mapping = this.config.openApiSpec.endpointMapping!;
+ if (path in mapping) {
+ throw new Error(
+ `The path ${path} is already configured for OpenApi hosting`,
+ );
+ }
+ mapping[path] = form;
+ }
+ }
+
protected _handleHttpRequest(request: Request, response: Response) {
return this.httpHandler.handleRequest(request, response);
}
@@ -399,21 +425,7 @@ export class RestServer extends Context implements Server, HttpServerLike {
);
specForm = specForm || {version: '3.0.0', format: 'json'};
- let specObj = this.getApiSpec();
- if (this.config.openApiSpec.setServersFromRequest) {
- specObj = Object.assign({}, specObj);
- specObj.servers = [{url: requestContext.requestedBaseUrl}];
- }
-
- const basePath = requestContext.basePath;
- if (specObj.servers && basePath) {
- for (const s of specObj.servers) {
- // Update the default server url to honor `basePath`
- if (s.url === '/') {
- s.url = basePath;
- }
- }
- }
+ const specObj = this.getApiSpec(requestContext);
if (specForm.format === 'json') {
const spec = JSON.stringify(specObj, null, 2);
@@ -675,9 +687,20 @@ export class RestServer extends Context implements Server, HttpServerLike {
* - `app.controller(MyController)`
* - `app.route(route)`
* - `app.route('get', '/greet', operationSpec, MyController, 'greet')`
+ *
+ * If the optional `requestContext` is provided, then the `servers` list
+ * in the returned spec will be updated to work in that context.
+ * Specifically:
+ * 1. if `config.openApi.setServersFromRequest` is enabled, the servers
+ * list will be replaced with the context base url
+ * 2. Any `servers` entries with a path of `/` will have that path
+ * replaced with `requestContext.basePath`
+ *
+ * @param requestContext - Optional context to update the `servers` list
+ * in the returned spec
*/
- getApiSpec(): OpenApiSpec {
- const spec = this.getSync(RestBindings.API_SPEC);
+ getApiSpec(requestContext?: RequestContext): OpenApiSpec {
+ let spec = this.getSync(RestBindings.API_SPEC);
const defs = this.httpHandler.getApiDefinitions();
// Apply deep clone to prevent getApiSpec() callers from
@@ -689,6 +712,40 @@ export class RestServer extends Context implements Server, HttpServerLike {
}
assignRouterSpec(spec, this._externalRoutes.routerSpec);
+
+ if (requestContext) {
+ spec = this.updateSpecFromRequest(spec, requestContext);
+ }
+
+ return spec;
+ }
+
+ /**
+ * Update or rebuild OpenAPI Spec object to be appropriate for the context of a specific request for the spec, leveraging both app config and request path information.
+ *
+ * @param spec base spec object from which to start
+ * @param requestContext request to use to infer path information
+ * @returns Updated or rebuilt spec object to use in the context of the request
+ */
+ private updateSpecFromRequest(
+ spec: OpenAPIObject,
+ requestContext: RequestContext,
+ ) {
+ if (this.config.openApiSpec.setServersFromRequest) {
+ spec = Object.assign({}, spec);
+ spec.servers = [{url: requestContext.requestedBaseUrl}];
+ }
+
+ const basePath = requestContext.basePath;
+ if (spec.servers && basePath) {
+ for (const s of spec.servers) {
+ // Update the default server url to honor `basePath`
+ if (s.url === '/') {
+ s.url = basePath;
+ }
+ }
+ }
+
return spec;
}
@@ -988,8 +1045,7 @@ function resolveRestServerConfig(
config: RestServerConfig,
): RestServerResolvedConfig {
const result: RestServerResolvedConfig = Object.assign(
- {},
- DEFAULT_CONFIG,
+ cloneDeep(DEFAULT_CONFIG),
config,
);
@@ -1003,8 +1059,11 @@ function resolveRestServerConfig(
result.host = undefined;
}
- if (!result.openApiSpec.endpointMapping)
- result.openApiSpec.endpointMapping = OPENAPI_SPEC_MAPPING;
+ if (!result.openApiSpec.endpointMapping) {
+ // mapping may be mutated by addOpenApiSpecEndpoint, be sure that doesn't
+ // pollute the default mapping configuration
+ result.openApiSpec.endpointMapping = cloneDeep(OPENAPI_SPEC_MAPPING);
+ }
result.apiExplorer = normalizeApiExplorerConfig(config.apiExplorer);