Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rest explorer now hosts its own copy of oas by default #3133

Merged
merged 1 commit into from
Sep 24, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it disable the spec endpoints exposed by @loopback/rest if the explorer component has its own?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it leaves those un-touched


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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make it easy to understand, maybe we should build a table to show different cases with corresponding configurations, such as:

Use case Recommended configuration Exposed spec/UI URL
Running as standalone server
Running behind a reverse proxy

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 will work on that

I think the scenarios to consider are:

  • Whether the app is exposed directly or not (i.e. does the request the app received have the original request host directly or in a reverse proxy provided header)
  • Whether there is a path-modifying reverse proxy in front of the app
  • Whether you have advanced an (undetermined) advanced use case where you need the explorer to use a custom OpenAPI spec instead of the LB4 generated one

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to add to more well-known cases to the list:

#### 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍

## 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