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 app<sup>1</sup> | yes | no | configure manually as host-relative path, as clients will see it | +| App behind path-modifying reverse proxy, modifications not known to app<sup>2</sup> | ? | ? | ? | +| App uses custom OpenAPI spec instead of LB4-generated one | no | depends on reverse-proxy configuration | depends on reverse-proxy configuration | + +<sup>1</sup> 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. + +<sup>2</sup> 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(/<title>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<RestServer>('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<OpenApiSpec>(RestBindings.API_SPEC); + getApiSpec(requestContext?: RequestContext): OpenApiSpec { + let spec = this.getSync<OpenApiSpec>(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);