From 9a028a2a5f4fa5f10eb0cf23bac251bd1f1bed8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 7 Sep 2020 10:28:56 +0200 Subject: [PATCH 1/2] feat(rest): remove support for HTTP redirect to an external API Explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: LoopBack no longer supports automatic redirect to an externally hosted API Explorer UI. Instead, we provide an extension that implements a self-hosted API Explorer UI. See the following documentation page for instructions on setting up a self-hosted API Explorer: - https://loopback.io/doc/en/lb4/Self-hosted-rest-api-explorer.html By default, applications scaffolded using `lb4 app` are coming with a pre-configured API Explorer. If you have scaffolded your application with `@loopback/cli` version `1.2.0` (published in Nov 2018) or later, then no changes are necessary in your project. Signed-off-by: Miroslav Bajtoš --- .../integration/rest.server.integration.ts | 176 ++---------------- packages/rest/src/rest.server.ts | 96 +++------- 2 files changed, 41 insertions(+), 231 deletions(-) diff --git a/packages/rest/src/__tests__/integration/rest.server.integration.ts b/packages/rest/src/__tests__/integration/rest.server.integration.ts index 8c58458f2ad8..85fc39640999 100644 --- a/packages/rest/src/__tests__/integration/rest.server.integration.ts +++ b/packages/rest/src/__tests__/integration/rest.server.integration.ts @@ -13,7 +13,6 @@ import { import {OpenAPIObject} from '@loopback/openapi-v3'; import { createClientForHandler, - createRestAppClient, expect, givenHttpServerConfig, httpsGetAsync, @@ -34,13 +33,13 @@ import { Request, requestBody, RequestContext, - RestApplication, RestBindings, RestComponent, RestServer, RestServerConfig, } from '../..'; import {RestTags} from '../../keys'; +import {ApiExplorerOptions} from '../../rest.server'; const readFileAsync = util.promisify(fs.readFile); const FIXTURES = path.resolve(__dirname, '../../../fixtures'); @@ -608,7 +607,7 @@ paths: ).to.throw(/already configured/); }); - it('exposes "GET /explorer" endpoint', async () => { + it('no longer exposes "GET /explorer" endpoint', async () => { const app = new Application(); app.component(RestComponent); const server = await app.getServer(RestServer); @@ -622,20 +621,9 @@ paths: }; server.route('get', '/greet', greetSpec, function greet() {}); - const response = await createClientForHandler(server.requestHandler).get( - '/explorer', - ); - await server.get(RestBindings.PORT); - const expectedUrl = new RegExp( - [ - 'http://explorer.loopback.io', - '\\?url=http://\\d+.\\d+.\\d+.\\d+:\\d+/openapi.json', - ].join(''), - ); - expect(response.status).to.equal(302); - expect(response.get('Location')).match(expectedUrl); - expect(response.get('Access-Control-Allow-Origin')).to.equal('*'); - expect(response.get('Access-Control-Allow-Credentials')).to.equal('true'); + await createClientForHandler(server.requestHandler) + .get('/explorer') + .expect(404); }); it('can be configured to disable "GET /explorer"', async () => { @@ -650,125 +638,17 @@ paths: await request.get('/explorer').expect(404); }); - it('honors "x-forwarded-*" headers', async () => { - const app = new Application(); - app.component(RestComponent); - const server = await app.getServer(RestServer); - - const response = await createClientForHandler(server.requestHandler) - .get('/explorer') - .set('x-forwarded-proto', 'https') - .set('x-forwarded-host', 'example.com') - .set('x-forwarded-port', '8080'); - await server.get(RestBindings.PORT); - const expectedUrl = new RegExp( - [ - 'https://explorer.loopback.io', - '\\?url=https://example.com:8080/openapi.json', - ].join(''), - ); - expect(response.get('Location')).match(expectedUrl); - }); - - it('honors "x-forwarded-host" headers', async () => { - const app = new Application(); - app.component(RestComponent); - const server = await app.getServer(RestServer); - - const response = await createClientForHandler(server.requestHandler) - .get('/explorer') - .set('x-forwarded-proto', 'http') - .set('x-forwarded-host', 'example.com:8080,my.example.com:9080'); - await server.get(RestBindings.PORT); - const expectedUrl = new RegExp( - [ - 'http://explorer.loopback.io', - '\\?url=http://example.com:8080/openapi.json', - ].join(''), - ); - expect(response.get('Location')).match(expectedUrl); - }); - - it('skips port if it is the default for http or https', async () => { - const app = new Application(); - app.component(RestComponent); - const server = await app.getServer(RestServer); - - const response = await createClientForHandler(server.requestHandler) - .get('/explorer') - .set('x-forwarded-proto', 'https') - .set('x-forwarded-host', 'example.com') - .set('x-forwarded-port', '443'); - await server.get(RestBindings.PORT); - const expectedUrl = new RegExp( - [ - 'https://explorer.loopback.io', - '\\?url=https://example.com/openapi.json', - ].join(''), - ); - expect(response.get('Location')).match(expectedUrl); - }); - - it('handles requests with missing Host header', async () => { - const app = new RestApplication({ - rest: {port: 0, host: '127.0.0.1'}, - }); - await app.start(); - const port = await app.restServer.get(RestBindings.PORT); - - const response = await createRestAppClient(app) - .get('/explorer') - .set('host', ''); - await app.stop(); - const expectedUrl = new RegExp(`\\?url=http://127.0.0.1:${port}`); - expect(response.get('Location')).match(expectedUrl); - }); - - it('exposes "GET /explorer" endpoint with apiExplorer.url', async () => { - const server = await givenAServer({ - rest: { - apiExplorer: { - url: 'https://petstore.swagger.io', - }, - }, - }); - - const response = await createClientForHandler(server.requestHandler).get( - '/explorer', - ); - await server.get(RestBindings.PORT); - const expectedUrl = new RegExp( - [ - 'https://petstore.swagger.io', - '\\?url=http://\\d+.\\d+.\\d+.\\d+:\\d+/openapi.json', - ].join(''), - ); - expect(response.status).to.equal(302); - expect(response.get('Location')).match(expectedUrl); - }); - - it('exposes "GET /explorer" endpoint with apiExplorer.urlForHttp', async () => { - const server = await givenAServer({ - rest: { - apiExplorer: { - url: 'https://petstore.swagger.io', - httpUrl: 'http://petstore.swagger.io', + it('throws an error when apiExplorer config is missing "disable" flag', () => { + return expect( + givenAServer({ + rest: { + ...givenHttpServerConfig(), + apiExplorer: {} as ApiExplorerOptions, }, - }, - }); - - const response = await createClientForHandler(server.requestHandler).get( - '/explorer', - ); - await server.get(RestBindings.PORT); - const expectedUrl = new RegExp( - [ - 'http://petstore.swagger.io', - '\\?url=http://\\d+.\\d+.\\d+.\\d+:\\d+/openapi.json', - ].join(''), + }), + ).to.be.rejectedWith( + /Externally hosted API Explorer is no longer supported/, ); - expect(response.status).to.equal(302); - expect(response.get('Location')).match(expectedUrl); }); it('supports HTTPS protocol with key and certificate files', async () => { @@ -825,34 +705,6 @@ paths: await server.stop(); }); - // https://github.com/strongloop/loopback-next/issues/1623 - skipOnTravis(it, 'handles IPv6 address for API Explorer UI', async () => { - const keyPath = path.join(FIXTURES, 'key.pem'); - const certPath = path.join(FIXTURES, 'cert.pem'); - const server = await givenAServer({ - rest: { - port: 0, - host: '::1', - protocol: 'https', - key: fs.readFileSync(keyPath), - cert: fs.readFileSync(certPath), - }, - }); - server.handler(dummyRequestHandler); - await server.start(); - const serverUrl = server.getSync(RestBindings.URL); - - // The `Location` header should be something like - // https://explorer.loopback.io?url=https://[::1]:58470/openapi.json - const res = await httpsGetAsync(serverUrl + '/explorer'); - const location = res.headers['location']; - expect(location).to.match(/\[\:\:1\]\:\d+\/openapi.json/); - expect(location).to.equal( - `https://explorer.loopback.io?url=${serverUrl}/openapi.json`, - ); - await server.stop(); - }); - it('honors HTTPS config binding after instantiation', async () => { const keyPath = path.join(FIXTURES, 'key.pem'); const certPath = path.join(FIXTURES, 'cert.pem'); diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 0eec31b27091..771feafe2a16 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -240,6 +240,15 @@ export class RestServer this.bind(RestBindings.BASE_PATH).toDynamicValue(() => this._basePath); this.bind(RestBindings.HANDLER).toDynamicValue(() => this.httpHandler); + + if (this.config.apiExplorer && !this.config.apiExplorer.disabled) { + throw new Error( + 'Externally hosted API Explorer is no longer supported. ' + + 'Please remove "apiExplorer" section from your REST configuration ' + + 'and configure a self-hosted API Explorer as explained here: ' + + 'https://loopback.io/doc/en/lb4/Self-hosted-rest-api-explorer.html', + ); + } } protected _setupOASEnhancerIfNeeded() { @@ -325,8 +334,7 @@ export class RestServer } /** - * Mount /openapi.json, /openapi.yaml for specs and /swagger-ui, /explorer - * to redirect to externally hosted API explorer + * Mount /openapi.json, /openapi.yaml for specs */ protected _setupOpenApiSpecEndpoints() { assertExists(this._expressApp, 'this._expressApp'); @@ -337,10 +345,6 @@ export class RestServer for (const p in mapping) { this.addOpenApiSpecEndpoint(p, mapping[p], router); } - const explorerPaths = ['/swagger-ui', '/explorer']; - router.get(explorerPaths, (req, res, next) => - this._redirectToSwaggerUI(req, res, next), - ); this.expressMiddleware('middleware.apiSpec.defaults', router, { group: RestMiddlewareGroups.API_SPEC, upstreamGroups: RestMiddlewareGroups.CORS, @@ -555,32 +559,6 @@ export class RestServer response.end(yaml, 'utf-8'); } } - private async _redirectToSwaggerUI( - request: Request, - response: Response, - next: express.NextFunction, - ) { - const config = this.config.apiExplorer; - - if (config.disabled) { - debug('Redirect to swagger-ui was disabled by configuration.'); - next(); - return; - } - - debug('Redirecting to swagger-ui from %j.', request.originalUrl); - const requestContext = new RequestContext( - request, - response, - this, - this.config, - ); - const protocol = requestContext.requestedProtocol; - const baseUrl = protocol === 'http' ? config.httpUrl : config.url; - const openApiUrl = `${requestContext.requestedBaseUrl}/openapi.json`; - const fullUrl = `${baseUrl}?url=${openApiUrl}`; - response.redirect(302, fullUrl); - } /** * Register a controller class with this server. @@ -1132,26 +1110,16 @@ export interface OpenApiSpecOptions { consolidate?: boolean; } +// TODO(semver-major) Remove this interface in the next major version +/** + * A legacy interface preserved for easier upgrade path. + */ export interface ApiExplorerOptions { - /** - * URL for the hosted API explorer UI - * default to https://loopback.io/api-explorer - */ - url?: string; - - /** - * URL for the API explorer served over `http` protocol to deal with mixed - * content security imposed by browsers as the spec is exposed over `http` by - * default. - * See https://github.com/strongloop/loopback-next/issues/1603 - */ - httpUrl?: string; - /** * Set this flag to disable the built-in redirect to externally * hosted API Explorer UI. */ - disabled?: true; + disabled: true; } /** @@ -1169,7 +1137,17 @@ export interface RestServerResolvedOptions { basePath?: string; cors: cors.CorsOptions; openApiSpec: OpenApiSpecOptions; - apiExplorer: ApiExplorerOptions; + + /** + * Configuration options for legacy API Explorer implemented as + * a redirect to externally hosted swagger-ui. + * + * @deprecated Externally hosted API Explorer is no longer supported, + * please use the self-hosted explorer instead. + * See https://loopback.io/doc/en/lb4/Self-hosted-rest-api-explorer.html + */ + apiExplorer?: ApiExplorerOptions; + requestBodyParser?: RequestBodyParserOptions; sequence?: Constructor; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1195,7 +1173,6 @@ export type RestServerResolvedConfig = RestServerResolvedOptions & const DEFAULT_CONFIG: RestServerResolvedConfig = { port: 3000, openApiSpec: {}, - apiExplorer: {}, cors: { origin: '*', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', @@ -1233,26 +1210,7 @@ function resolveRestServerConfig( result.openApiSpec.endpointMapping = cloneDeep(OPENAPI_SPEC_MAPPING); } - result.apiExplorer = normalizeApiExplorerConfig(config.apiExplorer); - - if (result.openApiSpec.disabled) { - // Disable apiExplorer if the OpenAPI spec endpoint is disabled - result.apiExplorer.disabled = true; - } + result.apiExplorer = config.apiExplorer; return result; } - -function normalizeApiExplorerConfig( - input: ApiExplorerOptions | undefined, -): ApiExplorerOptions { - const config = input ?? {}; - const url = config.url ?? 'https://explorer.loopback.io'; - - config.httpUrl = - config.httpUrl ?? config.url ?? 'http://explorer.loopback.io'; - - config.url = url; - - return config; -} From c942d9c949e863a01ce79f0bcefe79518193847b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 7 Sep 2020 10:29:14 +0200 Subject: [PATCH 2/2] feat(docs): explain dropping of support for external API Explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- docs/site/Binding.md | 17 ++++++++------ docs/site/Server.md | 55 ++++++++------------------------------------ 2 files changed, 19 insertions(+), 53 deletions(-) diff --git a/docs/site/Binding.md b/docs/site/Binding.md index a22d482689f3..0c6a893cff81 100644 --- a/docs/site/Binding.md +++ b/docs/site/Binding.md @@ -182,16 +182,19 @@ dependency is not needed, `toDynamicValue` can be used instead. #### An alias An alias is the key with optional path to resolve the value from another -binding. For example, if we want to get options from RestServer for the API -explorer, we can configure the `apiExplorer.options` to be resolved from -`servers.RestServer.options#apiExplorer`. +binding. For example, if we want to get options from RestServer for the OpenAPI +Spec endpoints, we can configure the `openApiSpec.options` to be resolved from +`servers.RestServer.options#openApiSpec`. ```ts -ctx.bind('servers.RestServer.options').to({apiExplorer: {path: '/explorer'}}); ctx - .bind('apiExplorer.options') - .toAlias('servers.RestServer.options#apiExplorer'); -const apiExplorerOptions = await ctx.get('apiExplorer.options'); // => {path: '/explorer'} + .bind('servers.RestServer.options') + .to({openApiSpec: {setServersFromRequest: true}}); +ctx + .bind('openApiSpec.options') + .toAlias('servers.RestServer.options#openApiSpec'); +const openApiSpecOptions = await ctx.get('openApiSpec.options'); +// => {setServersFromRequest: true} ``` ### Configure the scope diff --git a/docs/site/Server.md b/docs/site/Server.md index bf4a1755a756..b7d0eb557d4b 100644 --- a/docs/site/Server.md +++ b/docs/site/Server.md @@ -76,54 +76,17 @@ const app = new RestApplication({ ### Configure the API Explorer -LoopBack allows externally hosted API Explorer UI to render the OpenAPI -endpoints for a REST server. Such URLs can be specified with `rest.apiExplorer`: - -- url: URL for the hosted API Explorer UI, default to - `https://explorer.loopback.io`. -- httpUrl: URL for the API explorer served over plain http to deal with mixed - content security imposed by browsers as the spec is exposed over `http` by - default. See https://github.com/strongloop/loopback-next/issues/1603. Default - to the value of `url`. - -```ts -const app = new RestApplication({ - rest: { - apiExplorer: { - url: 'https://petstore.swagger.io', - httpUrl: 'http://petstore.swagger.io', - }, - }, -}); -``` - -#### Disable redirect to API Explorer - -To disable redirect to the externally hosted API Explorer, set the config option -`rest.apiExplorer.disabled` to `true`. - -```ts -const app = new RestApplication({ - rest: { - apiExplorer: { - disabled: true, - }, - }, -}); -``` - -{% include note.html content="To completely disable API Explorer, we also need -to [disable the self-hosted REST API Explorer extension](./Self-hosted-REST-API-Explorer.md#disable-self-hosted-api-explorer)." %} - -### Use a self-hosted API Explorer - -Hosting the API Explorer at an external URL has a few downsides, for example a -working internet connection is required to explore the API. As a recommended -alternative, LoopBack comes with an extension that provides a self-hosted -Explorer UI. Please refer to +Starting from `@loopback/rest` version `7.0.0`, LoopBack no longer supports +automatic redirect to an externally hosted API Explorer UI. Instead, we provide +an extension that implements a self-hosted API Explorer UI. Please refer to [Self-hosted REST API Explorer](./Self-hosted-REST-API-Explorer.md) for more details. +By default, applications scaffolded using `lb4 app` are coming with a +pre-configured API Explorer. See +[disable the self-hosted REST API Explorer extension](./Self-hosted-REST-API-Explorer.md#disable-self-hosted-api-explorer) +for instructions on how to disable this pre-configured API Explorer. + ### Customize CORS [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) is enabled @@ -247,7 +210,7 @@ for more details. | cors | CorsOptions | Specify the CORS options. | | sequence | SequenceHandler | Use a custom SequenceHandler to change the behavior of the RestServer for the request-response lifecycle. | | openApiSpec | OpenApiSpecOptions | Customize how OpenAPI spec is served | -| apiExplorer | ApiExplorerOptions | Customize how API explorer is served | +| apiExplorer | ApiExplorerOptions | **(DEPRECATED)** Customize how API explorer is served | | requestBodyParser | RequestBodyParserOptions | Customize how request body is parsed | | router | RouterOptions | Customize how trailing slashes are used for routing | | listenOnStart | boolean (default to true) | Control if the server should listen on http/https when it's started |