Skip to content

Commit

Permalink
feat: self host oas spec by default on relative path in explorer
Browse files Browse the repository at this point in the history
This makes it much easier to use the explorer with more complex
configurations such as base paths, express composition, and path-modifying
reverse proxies.
  • Loading branch information
mgabeler-lee-6rs authored and raymondfeng committed Sep 24, 2019
1 parent 8e1eae4 commit 887556e
Show file tree
Hide file tree
Showing 11 changed files with 430 additions and 106 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ 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 () => {
await client
.get('/api/explorer/')
.expect(200)
.expect('content-type', /html/)
.expect(/url\: '\/api\/openapi\.json'\,/)
.expect(/url\: '\.\/openapi\.json'\,/)
.expect(/<title>LoopBack API Explorer/);
});
});
4 changes: 0 additions & 4 deletions examples/express-composition/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
71 changes: 71 additions & 0 deletions packages/rest-explorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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'},
Expand All @@ -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',
});

Expand All @@ -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', () => {
Expand All @@ -130,12 +165,25 @@ describe('API Explorer (acceptance)', () => {
.expect(200)
.expect('content-type', /html/)
// OpenAPI endpoints DO NOT honor basePath
.expect(/url\: '\/openapi\.json'\,/);
.expect(/url\: '\.\/openapi\.json'\,/);
});
});

function givenRestApplication(config?: RestServerConfig) {
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);
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions packages/rest-explorer/src/rest-explorer.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
Loading

0 comments on commit 887556e

Please sign in to comment.