Skip to content

Commit 887556e

Browse files
mgabeler-lee-6rsraymondfeng
authored andcommitted
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.
1 parent 8e1eae4 commit 887556e

File tree

11 files changed

+430
-106
lines changed

11 files changed

+430
-106
lines changed

examples/express-composition/src/__tests__/acceptance/express.acceptance.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,17 @@ describe('ExpressApplication', () => {
4545
await client
4646
.get('/api/explorer')
4747
.expect(301)
48-
.expect('location', '/api/explorer/');
48+
// expect relative redirect so that it works seamlessly with many forms
49+
// of base path, whether within the app or applied by a reverse proxy
50+
.expect('location', './explorer/');
4951
});
5052

5153
it('displays explorer page', async () => {
5254
await client
5355
.get('/api/explorer/')
5456
.expect(200)
5557
.expect('content-type', /html/)
56-
.expect(/url\: '\/api\/openapi\.json'\,/)
58+
.expect(/url\: '\.\/openapi\.json'\,/)
5759
.expect(/<title>LoopBack API Explorer/);
5860
});
5961
});

examples/express-composition/src/application.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,6 @@ export class NoteApplication extends BootMixin(
2727
// Set up default home page
2828
this.static('/', path.join(__dirname, '../public'));
2929

30-
// Customize @loopback/rest-explorer configuration here
31-
this.bind(RestExplorerBindings.CONFIG).to({
32-
path: '/explorer',
33-
});
3430
this.component(RestExplorerComponent);
3531

3632
this.projectRoot = __dirname;

packages/rest-explorer/README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,77 @@ requesting a configuration option for customizing the visual style, please
5252
up-vote the issue and/or join the discussion if you are interested in this
5353
feature._
5454

55+
### Advanced Configuration and Reverse Proxies
56+
57+
By default, the component will add an additional OpenAPI spec endpoint, in the
58+
format it needs, at a fixed relative path to that of the Explorer itself. For
59+
example, in the default configuration, it will expose `/explorer/openapi.json`,
60+
or in the examples above with the Explorer path configured, it would expose
61+
`/openapi/ui/openapi.json`. This is to allow it to use a fixed relative path to
62+
load the spec, to be tolerant of running behind reverse proxies.
63+
64+
You may turn off this behavior in the component configuration, for example:
65+
66+
```ts
67+
this.configure(RestExplorerBindings.COMPONENT).to({
68+
useSelfHostedSpec: false,
69+
});
70+
```
71+
72+
If you do so, it will try to locate an existing configured OpenAPI spec endpoint
73+
of the required form in the REST Server configuration. This may be problematic
74+
when operating behind a reverse proxy that inserts a path prefix.
75+
76+
When operating behind a reverse proxy that does path changes, such as inserting
77+
a prefix on the path, using the default behavior for `useSelfHostedSpec` is the
78+
simplest option, but is not sufficient to have a functioning Explorer. You will
79+
also need to explicitly configure `rest.openApiSpec.servers` (in your
80+
application configuration object) to have an entry that has the correct host and
81+
path as seen by the _client_ browser.
82+
83+
Note that in this scenario, setting `rest.openApiSpec.setServersFromRequest` is
84+
not recommended, as it will cause the path information to be lost, as the
85+
standards for HTTP reverse proxies only provide means to tell the proxied server
86+
(your app) about the _hostname_ used for the original request, not the full
87+
original _path_.
88+
89+
Note also that you cannot use a url-relative path for the `servers` entry, as
90+
the Swagger UI does not support that (yet). You may use a _host_-relative path
91+
however.
92+
93+
#### Summary
94+
95+
For some common scenarios, here are recommended configurations to have the
96+
explorer working properly. Note that these are not the _only_ configurations
97+
that will work reliably, they are just the _simplest_ ones to setup.
98+
99+
| Scenario | `useSelfHostedSpec` | `setServersFromRequest` | `servers` |
100+
| ----------------------------------------------------------------------------------- | ------------------- | -------------------------------------- | ---------------------------------------------------------------- |
101+
| App exposed directly | yes | either | automatic |
102+
| App behind simple reverse proxy | yes | yes | automatic |
103+
| App exposed directly or behind simple proxy, with a `basePath` set | yes | yes | automatic |
104+
| App exposed directly or behind simple proxy, mounted inside another express app | yes | yes | automatic |
105+
| 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 |
106+
| App behind path-modifying reverse proxy, modifications not known to app<sup>2</sup> | ? | ? | ? |
107+
| App uses custom OpenAPI spec instead of LB4-generated one | no | depends on reverse-proxy configuration | depends on reverse-proxy configuration |
108+
109+
<sup>1</sup> The modifications need to be known to the app at build or startup
110+
time so that you can manually configure the `servers` list. For example, if you
111+
know that your reverse proxy is going to expose the root of your app at
112+
`/foo/bar/`, then you would set the first of your `servers` entries to
113+
`/foo/bar`. This scenario also cases where the app is using a `basePath` or is
114+
mounted inside another express app, with this same reverse proxy setup. In those
115+
cases the manually configured `servers` entry will need to account for the path
116+
prefixes the `basePath` or express embedding adds in addition to what the
117+
reverse proxy does.
118+
119+
<sup>2</sup> Due to limitations in the OpenAPI spec and what information is
120+
provided by the reverse proxy to the app, this is a scenario without a clear
121+
standards-based means of getting a working explorer. A custom solution would be
122+
needed in this situation, such as passing a non-standard header from your
123+
reverse proxy to tell the app the external path, and custom code in your app to
124+
make the app and explorer aware of this.
125+
55126
## Contributions
56127

57128
- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md)

packages/rest-explorer/src/__tests__/acceptance/rest-explorer.acceptance.ts

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,19 @@ describe('API Explorer (acceptance)', () => {
4545
await request
4646
.get('/explorer')
4747
.expect(301)
48-
.expect('location', '/explorer/');
48+
// expect relative redirect so that it works seamlessly with many forms
49+
// of base path, whether within the app or applied by a reverse proxy
50+
.expect('location', './explorer/');
4951
});
5052

51-
it('configures swagger-ui with OpenAPI spec url "/openapi.json', async () => {
53+
it('configures swagger-ui with OpenAPI spec url "./openapi.json', async () => {
5254
const response = await request.get('/explorer/').expect(200);
5355
const body = response.body;
54-
expect(body).to.match(/^\s*url: '\/openapi.json',\s*$/m);
56+
expect(body).to.match(/^\s*url: '\.\/openapi.json',\s*$/m);
57+
});
58+
59+
it('hosts OpenAPI at "./openapi.json', async () => {
60+
await request.get('/explorer/openapi.json').expect(200);
5561
});
5662

5763
it('mounts swagger-ui assets at "/explorer"', async () => {
@@ -61,8 +67,8 @@ describe('API Explorer (acceptance)', () => {
6167
});
6268

6369
context('with custom RestServerConfig', () => {
64-
it('honours custom OpenAPI path', async () => {
65-
await givenAppWithCustomRestConfig({
70+
it('uses self-hosted spec by default', async () => {
71+
await givenAppWithCustomExplorerConfig({
6672
openApiSpec: {
6773
endpointMapping: {
6874
'/apispec': {format: 'json', version: '3.0.0'},
@@ -74,20 +80,34 @@ describe('API Explorer (acceptance)', () => {
7480

7581
const response = await request.get('/explorer/').expect(200);
7682
const body = response.body;
77-
expect(body).to.match(/^\s*url: '\/apispec',\s*$/m);
83+
expect(body).to.match(/^\s*url: '\.\/openapi.json',\s*$/m);
7884
});
7985

80-
async function givenAppWithCustomRestConfig(config: RestServerConfig) {
81-
app = givenRestApplication(config);
82-
app.component(RestExplorerComponent);
83-
await app.start();
84-
request = createRestAppClient(app);
85-
}
86+
it('honors flag to disable self-hosted spec', async () => {
87+
await givenAppWithCustomExplorerConfig(
88+
{
89+
openApiSpec: {
90+
endpointMapping: {
91+
'/apispec': {format: 'json', version: '3.0.0'},
92+
'/apispec/v2': {format: 'json', version: '2.0.0'},
93+
'/apispec/yaml': {format: 'yaml', version: '3.0.0'},
94+
},
95+
},
96+
},
97+
{
98+
useSelfHostedSpec: false,
99+
},
100+
);
101+
102+
const response = await request.get('/explorer/').expect(200);
103+
const body = response.body;
104+
expect(body).to.match(/^\s*url: '\/apispec',\s*$/m);
105+
});
86106
});
87107

88108
context('with custom RestExplorerConfig', () => {
89109
it('honors custom explorer path', async () => {
90-
await givenAppWithCustomExplorerConfig({
110+
await givenAppWithCustomExplorerConfig(undefined, {
91111
path: '/openapi/ui',
92112
});
93113

@@ -98,20 +118,35 @@ describe('API Explorer (acceptance)', () => {
98118
await request
99119
.get('/openapi/ui')
100120
.expect(301)
101-
.expect('Location', '/openapi/ui/');
121+
// expect relative redirect so that it works seamlessly with many forms
122+
// of base path, whether within the app or applied by a reverse proxy
123+
.expect('Location', './ui/');
102124

103125
await request.get('/explorer').expect(404);
104126
});
105127

106-
async function givenAppWithCustomExplorerConfig(
107-
config: RestExplorerConfig,
108-
) {
109-
app = givenRestApplication();
110-
app.configure(RestExplorerBindings.COMPONENT).to(config);
111-
app.component(RestExplorerComponent);
112-
await app.start();
113-
request = createRestAppClient(app);
114-
}
128+
it('honors flag to disable self-hosted spec', async () => {
129+
await givenAppWithCustomExplorerConfig(undefined, {
130+
path: '/openapi/ui',
131+
useSelfHostedSpec: false,
132+
});
133+
134+
const response = await request.get('/openapi/ui/').expect(200);
135+
const body = response.body;
136+
expect(body).to.match(/<title>LoopBack API Explorer/);
137+
expect(body).to.match(/^\s*url: '\/openapi.json',\s*$/m);
138+
139+
await request
140+
.get('/openapi/ui')
141+
.expect(301)
142+
// expect relative redirect so that it works seamlessly with many forms
143+
// of base path, whether within the app or applied by a reverse proxy
144+
.expect('Location', './ui/');
145+
146+
await request.get('/explorer').expect(404);
147+
await request.get('/explorer/openapi.json').expect(404);
148+
await request.get('/openapi/ui/openapi.json').expect(404);
149+
});
115150
});
116151

117152
context('with custom basePath', () => {
@@ -130,12 +165,25 @@ describe('API Explorer (acceptance)', () => {
130165
.expect(200)
131166
.expect('content-type', /html/)
132167
// OpenAPI endpoints DO NOT honor basePath
133-
.expect(/url\: '\/openapi\.json'\,/);
168+
.expect(/url\: '\.\/openapi\.json'\,/);
134169
});
135170
});
136171

137172
function givenRestApplication(config?: RestServerConfig) {
138173
const rest = Object.assign({}, givenHttpServerConfig(), config);
139174
return new RestApplication({rest});
140175
}
176+
177+
async function givenAppWithCustomExplorerConfig(
178+
config?: RestServerConfig,
179+
explorerConfig?: RestExplorerConfig,
180+
) {
181+
app = givenRestApplication(config);
182+
if (explorerConfig) {
183+
app.bind(RestExplorerBindings.CONFIG).to(explorerConfig);
184+
}
185+
app.component(RestExplorerComponent);
186+
await app.start();
187+
request = createRestAppClient(app);
188+
}
141189
});

packages/rest-explorer/src/__tests__/acceptance/rest-explorer.express.acceptance.ts

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,47 +10,90 @@ import {
1010
givenHttpServerConfig,
1111
} from '@loopback/testlab';
1212
import * as express from 'express';
13-
import {RestExplorerComponent} from '../..';
13+
import {
14+
RestExplorerBindings,
15+
RestExplorerComponent,
16+
RestExplorerConfig,
17+
} from '../..';
1418

1519
describe('REST Explorer mounted as an express router', () => {
1620
let client: Client;
1721
let expressApp: express.Application;
1822
let server: RestServer;
19-
beforeEach(givenLoopBackApp);
20-
beforeEach(givenExpressApp);
21-
beforeEach(givenClient);
23+
context('default explorer config', () => {
24+
beforeEach(givenLoopBackApp);
25+
beforeEach(givenExpressApp);
26+
beforeEach(givenClient);
2227

23-
it('exposes API Explorer at "/api/explorer/"', async () => {
24-
await client
25-
.get('/api/explorer/')
26-
.expect(200)
27-
.expect('content-type', /html/)
28-
.expect(/url\: '\/api\/openapi\.json'\,/);
29-
});
28+
it('exposes API Explorer at "/api/explorer/"', async () => {
29+
await client
30+
.get('/api/explorer/')
31+
.expect(200)
32+
.expect('content-type', /html/)
33+
.expect(/url\: '\.\/openapi\.json'\,/);
34+
});
3035

31-
it('redirects from "/api/explorer" to "/api/explorer/"', async () => {
32-
await client
33-
.get('/api/explorer')
34-
.expect(301)
35-
.expect('location', '/api/explorer/');
36+
it('redirects from "/api/explorer" to "/api/explorer/"', async () => {
37+
await client
38+
.get('/api/explorer')
39+
.expect(301)
40+
// expect relative redirect so that it works seamlessly with many forms
41+
// of base path, whether within the app or applied by a reverse proxy
42+
.expect('location', './explorer/');
43+
});
44+
45+
it('uses correct URLs when basePath is set', async () => {
46+
server.basePath('/v1');
47+
await client
48+
// static assets (including swagger-ui) honor basePath
49+
.get('/api/v1/explorer/')
50+
.expect(200)
51+
.expect('content-type', /html/)
52+
// OpenAPI endpoints DO NOT honor basePath
53+
.expect(/url\: '\.\/openapi\.json'\,/);
54+
});
3655
});
3756

38-
it('uses correct URLs when basePath is set', async () => {
39-
server.basePath('/v1');
40-
await client
41-
// static assets (including swagger-ui) honor basePath
42-
.get('/api/v1/explorer/')
43-
.expect(200)
44-
.expect('content-type', /html/)
45-
// OpenAPI endpoints DO NOT honor basePath
46-
.expect(/url\: '\/api\/openapi\.json'\,/);
57+
context('self hosted api disabled', () => {
58+
beforeEach(givenLoopbackAppWithoutSelfHostedSpec);
59+
beforeEach(givenExpressApp);
60+
beforeEach(givenClient);
61+
62+
it('exposes API Explorer at "/api/explorer/"', async () => {
63+
await client
64+
.get('/api/explorer/')
65+
.expect(200)
66+
.expect('content-type', /html/)
67+
.expect(/url\: '\/api\/openapi\.json'\,/);
68+
});
69+
70+
it('uses correct URLs when basePath is set', async () => {
71+
server.basePath('/v1');
72+
await client
73+
// static assets (including swagger-ui) honor basePath
74+
.get('/api/v1/explorer/')
75+
.expect(200)
76+
.expect('content-type', /html/)
77+
// OpenAPI endpoints DO NOT honor basePath
78+
.expect(/url\: '\/api\/openapi\.json'\,/);
79+
});
80+
81+
async function givenLoopbackAppWithoutSelfHostedSpec() {
82+
return givenLoopBackApp(undefined, {
83+
useSelfHostedSpec: false,
84+
});
85+
}
4786
});
4887

4988
async function givenLoopBackApp(
5089
options: {rest: RestServerConfig} = {rest: {port: 0}},
90+
explorerConfig?: RestExplorerConfig,
5191
) {
5292
options.rest = givenHttpServerConfig(options.rest);
5393
const app = new RestApplication(options);
94+
if (explorerConfig) {
95+
app.bind(RestExplorerBindings.CONFIG).to(explorerConfig);
96+
}
5497
app.component(RestExplorerComponent);
5598
server = await app.getServer(RestServer);
5699
}

packages/rest-explorer/src/rest-explorer.component.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export class RestExplorerComponent implements Component {
2828

2929
this.registerControllerRoute('get', explorerPath, 'indexRedirect');
3030
this.registerControllerRoute('get', explorerPath + '/', 'index');
31+
if (restExplorerConfig.useSelfHostedSpec !== false) {
32+
this.registerControllerRoute(
33+
'get',
34+
explorerPath + '/openapi.json',
35+
'spec',
36+
);
37+
}
3138

3239
application.static(explorerPath, swaggerUI.getAbsoluteFSPath());
3340

0 commit comments

Comments
 (0)