From f7a541788c79985c22220487b987f48ed6e9cfaf Mon Sep 17 00:00:00 2001 From: Matthew Gabeler-Lee Date: Wed, 12 Jun 2019 18:44:00 -0400 Subject: [PATCH] feat: self host oas spec by default on relative path in explorer This makes it much easier to use the explorer with more complex configurations such as base paths, express composition, and path-modifying reverse proxies. --- .../acceptance/express.acceptance.ts | 6 +- .../express-composition/src/application.ts | 4 - packages/rest-explorer/README.md | 71 ++++++++++ .../acceptance/rest-explorer.acceptance.ts | 96 ++++++++++---- .../rest-explorer.express.acceptance.ts | 93 ++++++++++---- .../src/rest-explorer.component.ts | 7 + .../src/rest-explorer.controller.ts | 73 ++++++++--- .../rest-explorer/src/rest-explorer.types.ts | 18 +++ .../integration/rest.server.integration.ts | 41 ++++++ packages/rest/src/keys.ts | 6 + packages/rest/src/rest.server.ts | 121 +++++++++++++----- 11 files changed, 430 insertions(+), 106 deletions(-) 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);