From b56fb373ab3d63e4105fa07c3922a4ca4b23bb2e Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 22 Aug 2018 18:28:51 -0700 Subject: [PATCH] feat(rest): make servers configurable for openapi specs --- docs/site/Server.md | 81 +++++- packages/rest/README.md | 18 +- packages/rest/src/rest.component.ts | 7 +- packages/rest/src/rest.server.ts | 244 ++++++++++++------ .../integration/rest.server.integration.ts | 137 ++++++++-- 5 files changed, 367 insertions(+), 120 deletions(-) diff --git a/docs/site/Server.md b/docs/site/Server.md index 987f204c0ff4..41bd48055872 100644 --- a/docs/site/Server.md +++ b/docs/site/Server.md @@ -58,6 +58,73 @@ export class HelloWorldApp extends RestApplication { ## Configuration +The REST server can be configured by passing a `rest` property inside your +RestApplication options. For example, the following code customizes the port +number that a REST server listens on. + +```ts +const app = new RestApplication({ + rest: { + port: 3001, + }, +}); +``` + +### Customize How OpenAPI Spec is Served + +There are a few options under `rest.openApiSpec` to configure how OpenAPI spec +is served by the given REST server. + +- servers: Configure servers for OpenAPI spec +- setServersFromRequest: Set `servers` based on HTTP request headers, default to + `false` +- endpointMapping: Maps urls for various forms of the spec. Default to: + +```js + { + '/openapi.json': {version: '3.0.0', format: 'json'}, + '/openapi.yaml': {version: '3.0.0', format: 'yaml'}, + } +``` + +```ts +const app = new RestApplication({ + rest: { + openApiSpec: { + servers: [{url: 'http://127.0.0.1:8080'}], + setServersFromRequest: false, + endpointMapping: { + '/openapi.json': {version: '3.0.0', format: 'json'}, + '/openapi.yaml': {version: '3.0.0', format: 'yaml'}, + }, + }, + }, +}); +``` + +### 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://loopback.io/api-explorer`. +- 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', + }, + }, +}); +``` + ### Enable HTTPS Enabling HTTPS for the LoopBack REST server is just a matter of specifying the @@ -89,7 +156,19 @@ export async function main() { } ``` -### Add servers to application instance +### `rest` options + +| Property | Type | Purpose | +| ----------- | ------------------- | --------------------------------------------------------------------------------------------------------- | +| port | number | Specify the port on which the RestServer will listen for traffic. | +| protocol | string (http/https) | Specify the protocol on which the RestServer will listen for traffic. | +| key | string | Specify the SSL private key for https. | +| cert | string | Specify the SSL certificate for https. | +| 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 | + +## Add servers to application instance You can add server instances to your application via the `app.server()` method individually or as an array using `app.servers()` method. Using `app.server()` diff --git a/packages/rest/README.md b/packages/rest/README.md index 1fbf3dac60b3..3289a5a06afd 100644 --- a/packages/rest/README.md +++ b/packages/rest/README.md @@ -45,23 +45,7 @@ app.handler(({request, response}, sequence) => { ## Configuration -The rest package is configured by passing a `rest` property inside of your -Application options. - -```ts -const app = new RestApplication({ - rest: { - port: 3001, - }, -}); -``` - -### `rest` options - -| Property | Type | Purpose | -| -------- | --------------- | --------------------------------------------------------------------------------------------------------- | -| port | number | Specify the port on which the RestServer will listen for traffic. | -| sequence | SequenceHandler | Use a custom SequenceHandler to change the behavior of the RestServer for the request-response lifecycle. | +See https://loopback.io/doc/en/lb4/Server.html#configuration. ## Contributions diff --git a/packages/rest/src/rest.component.ts b/packages/rest/src/rest.component.ts index 1204335719ca..03d0273cd53c 100644 --- a/packages/rest/src/rest.component.ts +++ b/packages/rest/src/rest.component.ts @@ -48,7 +48,12 @@ export class RestComponent implements Component { @inject(RestBindings.CONFIG) config?: RestComponentConfig, ) { app.bind(RestBindings.SEQUENCE).toClass(DefaultSequence); - app.bind(RestBindings.API_SPEC).to(createEmptyApiSpec()); + const apiSpec = createEmptyApiSpec(); + // Merge the OpenAPI `servers` spec from the config into the empty one + if (config && config.openApiSpec && config.openApiSpec.servers) { + Object.assign(apiSpec, {servers: config.openApiSpec.servers}); + } + app.bind(RestBindings.API_SPEC).to(apiSpec); } } diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 64258e2b30d7..97ff093b7b6f 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -15,7 +15,11 @@ import { ControllerInstance, createControllerFactoryForBinding, } from './router/routing-table'; -import {OpenApiSpec, OperationObject} from '@loopback/openapi-v3-types'; +import { + OpenApiSpec, + OperationObject, + ServerObject, +} from '@loopback/openapi-v3-types'; import {ServerRequest, ServerResponse} from 'http'; import {HttpServer, HttpServerOptions} from '@loopback/http-server'; import * as cors from 'cors'; @@ -58,22 +62,6 @@ const SequenceActions = RestBindings.SequenceActions; // a non-module entity and cannot be imported using this construct. const cloneDeep: (value: T) => T = require('lodash/cloneDeep'); -/** - * The object format used for building the template bases of our OpenAPI spec - * files. - * - * @interface OpenApiSpecOptions - */ -interface OpenApiSpecOptions { - version?: string; - format?: string; -} - -const OPENAPI_SPEC_MAPPING: {[key: string]: OpenApiSpecOptions} = { - '/openapi.json': {version: '3.0.0', format: 'json'}, - '/openapi.yaml': {version: '3.0.0', format: 'yaml'}, -}; - /** * A REST API server for use with Loopback. * Add this server to your application by importing the RestComponent. @@ -126,6 +114,7 @@ export class RestServer extends Context implements Server, HttpServerLike { */ public requestHandler: HttpRequestListener; + public readonly config: RestServerConfig; protected _httpHandler: HttpHandler; protected get httpHandler(): HttpHandler { this._setupHandlerIfNeeded(); @@ -150,47 +139,62 @@ export class RestServer extends Context implements Server, HttpServerLike { * * @param {Application} app The application instance (injected via * CoreBindings.APPLICATION_INSTANCE). - * @param {RestServerConfig=} options The configuration options (injected via + * @param {RestServerConfig=} config The configuration options (injected via * RestBindings.CONFIG). * */ constructor( @inject(CoreBindings.APPLICATION_INSTANCE) app: Application, - @inject(RestBindings.CONFIG) options?: RestServerConfig, + @inject(RestBindings.CONFIG) config?: RestServerConfig, ) { super(app); - options = options || {}; + config = config || {}; // Can't check falsiness, 0 is a valid port. - if (options.port == null) { - options.port = 3000; + if (config.port == null) { + config.port = 3000; } - if (options.host == null) { + if (config.host == null) { // Set it to '' so that the http server will listen on all interfaces - options.host = undefined; + config.host = undefined; } - this.bind(RestBindings.PORT).to(options.port); - this.bind(RestBindings.HOST).to(options.host); - this.bind(RestBindings.PROTOCOL).to(options.protocol || 'http'); - this.bind(RestBindings.HTTPS_OPTIONS).to(options); - if (options.sequence) { - this.sequence(options.sequence); + config.openApiSpec = config.openApiSpec || {}; + config.openApiSpec.endpointMapping = + config.openApiSpec.endpointMapping || OPENAPI_SPEC_MAPPING; + config.apiExplorer = config.apiExplorer || {}; + + config.apiExplorer.url = + config.apiExplorer.url || 'https://loopback.io/api-explorer'; + + config.apiExplorer.httpUrl = + config.apiExplorer.httpUrl || + config.apiExplorer.url || + 'https://loopback.io/api-explorer'; + + this.config = config; + this.bind(RestBindings.PORT).to(config.port); + this.bind(RestBindings.HOST).to(config.host); + this.bind(RestBindings.PROTOCOL).to(config.protocol || 'http'); + this.bind(RestBindings.HTTPS_OPTIONS).to(config); + + if (config.sequence) { + this.sequence(config.sequence); } - this._setupRequestHandler(options); + this._setupRequestHandler(); this.bind(RestBindings.HANDLER).toDynamicValue(() => this.httpHandler); } - protected _setupRequestHandler(options: RestServerConfig) { + protected _setupRequestHandler() { this._expressApp = express(); this.requestHandler = this._expressApp; // Allow CORS support for all endpoints so that users // can test with online SwaggerUI instance - const corsOptions = options.cors || { + const corsOptions = this.config.cors || { origin: '*', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', preflightContinue: false, @@ -203,9 +207,12 @@ export class RestServer extends Context implements Server, HttpServerLike { // Place the assets router here before controllers this._setupRouterForStaticAssets(); + // Set up endpoints for OpenAPI spec/ui + this._setupOpenApiSpecEndpoints(); + // Mount our router & request handler this._expressApp.use((req, res, next) => { - this._handleHttpRequest(req, res, options!).catch(next); + this._handleHttpRequest(req, res).catch(next); }); // Mount our error handler @@ -227,33 +234,31 @@ export class RestServer extends Context implements Server, HttpServerLike { } } - protected _handleHttpRequest( - request: Request, - response: Response, - options: RestServerConfig, - ) { - if ( - request.method === 'GET' && - request.url && - request.url in OPENAPI_SPEC_MAPPING - ) { - // 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 settings = OPENAPI_SPEC_MAPPING[request.url]; - return this._serveOpenApiSpec(request, response, settings); - } - if ( - request.method === 'GET' && - request.url && - request.url === '/swagger-ui' - ) { - return this._redirectToSwaggerUI(request, response, options); + /** + * Mount /openapi.json, /openapi.yaml for specs and /swagger-ui, /api-explorer + * to redirect to externally hosted API explorer + */ + protected _setupOpenApiSpecEndpoints() { + // 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.use(p, (req, res) => + this._serveOpenApiSpec(req, res, mapping[p]), + ); } + this._expressApp.get(['/swagger-ui', '/api-explorer'], (req, res) => + this._redirectToSwaggerUI(req, res), + ); + } + + protected _handleHttpRequest(request: Request, response: Response) { return this.httpHandler.handleRequest(request, response); } @@ -354,11 +359,16 @@ export class RestServer extends Context implements Server, HttpServerLike { private async _serveOpenApiSpec( request: Request, response: Response, - options?: OpenApiSpecOptions, + specForm?: OpenApiSpecForm, ) { - options = options || {version: '3.0.0', format: 'json'}; + specForm = specForm || {version: '3.0.0', format: 'json'}; let specObj = this.getApiSpec(); - if (options.format === 'json') { + if (this.config.openApiSpec!.setServersFromRequest) { + specObj = Object.assign({}, specObj); + specObj.servers = [{url: this._getUrlForClient(request)}]; + } + + if (specForm.format === 'json') { const spec = JSON.stringify(specObj, null, 2); response.setHeader('content-type', 'application/json; charset=utf-8'); response.end(spec, 'utf-8'); @@ -370,22 +380,35 @@ export class RestServer extends Context implements Server, HttpServerLike { } /** - * Get the URL of the request sent by the client + * Get the protocol for a request * @param request Http request */ - private _getUrlForClient(request: Request, options: RestServerConfig) { - const protocol = + private _getProtocolForRequest(request: Request) { + return ( (request.get('x-forwarded-proto') || '').split(',')[0] || request.protocol || - options.protocol || - 'http'; + this.config.protocol || + 'http' + ); + } + /** + * Get the URL of the request sent by the client + * @param request Http request + */ + private _getUrlForClient(request: Request) { + const protocol = this._getProtocolForRequest(request); + // The host can be in one of the forms + // [::1]:3000 + // [::1] + // 127.0.0.1:3000 + // 127.0.0.1 let host = (request.get('x-forwarded-host') || '').split(',')[0] || - request.headers.host!.replace(/:[0-9]+/, ''); + request.headers.host!.replace(/:[0-9]+$/, ''); let port = (request.get('x-forwarded-port') || '').split(',')[0] || - options.port || - (request.headers.host!.match(/:([0-9]+)/) || [])[1] || + this.config.port || + (request.headers.host!.match(/:([0-9]+)$/) || [])[1] || ''; // clear default ports @@ -398,17 +421,13 @@ export class RestServer extends Context implements Server, HttpServerLike { return protocol + '://' + host; } - private async _redirectToSwaggerUI( - request: Request, - response: Response, - options: RestServerConfig, - ) { + private async _redirectToSwaggerUI(request: Request, response: Response) { + const protocol = this._getProtocolForRequest(request); const baseUrl = - options.apiExplorerUrl || 'https://loopback.io/api-explorer'; - const openApiUrl = `${this._getUrlForClient( - request, - options, - )}/openapi.json`; + protocol === 'http' + ? this.config.apiExplorer!.httpUrl + : this.config.apiExplorer!.url; + const openApiUrl = `${this._getUrlForClient(request)}/openapi.json`; const fullUrl = `${baseUrl}?url=${openApiUrl}`; response.redirect(308, fullUrl); } @@ -703,9 +722,70 @@ export class RestServer extends Context implements Server, HttpServerLike { } } +/** + * The form of OpenAPI specs to be served + * + * @interface OpenApiSpecForm + */ +export interface OpenApiSpecForm { + version?: string; + format?: string; +} + +const OPENAPI_SPEC_MAPPING: {[key: string]: OpenApiSpecForm} = { + '/openapi.json': {version: '3.0.0', format: 'json'}, + '/openapi.yaml': {version: '3.0.0', format: 'yaml'}, +}; + +/** + * Options to customize how OpenAPI specs are served + */ +export interface OpenApiSpecOptions { + /** + * Mapping of urls to spec forms, by default: + * ``` + * { + * '/openapi.json': {version: '3.0.0', format: 'json'}, + * '/openapi.yaml': {version: '3.0.0', format: 'yaml'}, + * } + * ``` + */ + endpointMapping?: {[key: string]: OpenApiSpecForm}; + + /** + * A flag to force `servers` to be set from the http request for the OpenAPI + * spec + */ + setServersFromRequest?: boolean; + + /** + * Configure servers for OpenAPI spec + */ + servers?: ServerObject[]; +} + +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; +} + +/** + * Options for RestServer configuration + */ export interface RestServerOptions { cors?: cors.CorsOptions; - apiExplorerUrl?: string; + openApiSpec?: OpenApiSpecOptions; + apiExplorer?: ApiExplorerOptions; sequence?: Constructor; } diff --git a/packages/rest/test/integration/rest.server.integration.ts b/packages/rest/test/integration/rest.server.integration.ts index fe596f18e7da..7ab42d826374 100644 --- a/packages/rest/test/integration/rest.server.integration.ts +++ b/packages/rest/test/integration/rest.server.integration.ts @@ -223,7 +223,11 @@ describe('RestServer (integration)', () => { }); it('exposes "GET /openapi.json" endpoint', async () => { - const server = await givenAServer({rest: {port: 0}}); + const server = await givenAServer({ + rest: { + port: 0, + }, + }); const greetSpec = { responses: { 200: { @@ -239,8 +243,11 @@ describe('RestServer (integration)', () => { ); expect(response.body).to.containDeep({ openapi: '3.0.0', + info: { + title: 'LoopBack Application', + version: '1.0.0', + }, servers: [{url: '/'}], - info: {title: 'LoopBack Application', version: '1.0.0'}, paths: { '/greet': { get: { @@ -262,6 +269,55 @@ describe('RestServer (integration)', () => { expect(response.get('Access-Control-Allow-Credentials')).to.equal('true'); }); + it('exposes "GET /openapi.json" with openApiSpec.servers', async () => { + const server = await givenAServer({ + rest: { + port: 0, + openApiSpec: { + servers: [{url: 'http://127.0.0.1:8080'}], + }, + }, + }); + + const response = await createClientForHandler(server.requestHandler).get( + '/openapi.json', + ); + expect(response.body.servers).to.eql([{url: 'http://127.0.0.1:8080'}]); + }); + + it('exposes "GET /openapi.json" with openApiSpec.setServersFromRequest', async () => { + const server = await givenAServer({ + rest: { + port: 0, + openApiSpec: { + setServersFromRequest: true, + }, + }, + }); + + const response = await createClientForHandler(server.requestHandler).get( + '/openapi.json', + ); + expect(response.body.servers[0].url).to.match(/http:\/\/127.0.0.1\:\d+/); + }); + + it('exposes endpoints with openApiSpec.endpointMapping', async () => { + const server = await givenAServer({ + rest: { + port: 0, + openApiSpec: { + endpointMapping: { + '/openapi': {version: '3.0.0', format: 'yaml'}, + }, + }, + }, + }); + + const test = createClientForHandler(server.requestHandler); + await test.get('/openapi').expect(200, /openapi\: 3\.0\.0/); + await test.get('/openapi.json').expect(404); + }); + it('exposes "GET /openapi.yaml" endpoint', async () => { const server = await givenAServer({rest: {port: 0}}); const greetSpec = { @@ -292,11 +348,12 @@ paths: 'text/plain': schema: type: string -servers: - - url: / `); // Use json for comparison to tolerate textual diffs - expect(yaml.safeLoad(response.text)).to.eql(expected); + const json = yaml.safeLoad(response.text); + expect(json).to.containDeep(expected); + expect(json.servers[0].url).to.match('/'); + expect(response.get('Access-Control-Allow-Origin')).to.equal('*'); expect(response.get('Access-Control-Allow-Credentials')).to.equal('true'); }); @@ -359,21 +416,37 @@ servers: expect(response.get('Location')).match(expectedUrl); }); - it('exposes "GET /swagger-ui" endpoint with apiExplorerUrl', async () => { - const app = new Application({ - rest: {apiExplorerUrl: 'http://petstore.swagger.io'}, + it('exposes "GET /swagger-ui" endpoint with apiExplorer.url', async () => { + const server = await givenAServer({ + rest: { + apiExplorer: { + url: 'https://petstore.swagger.io', + }, + }, }); - app.component(RestComponent); - const server = await app.getServer(RestServer); - const greetSpec = { - responses: { - 200: { - schema: {type: 'string'}, - description: 'greeting of the day', + + const response = await createClientForHandler(server.requestHandler).get( + '/swagger-ui', + ); + 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.get('Location')).match(expectedUrl); + }); + + it('exposes "GET /swagger-ui" endpoint with apiExplorer.urlForHttp', async () => { + const server = await givenAServer({ + rest: { + apiExplorer: { + url: 'https://petstore.swagger.io', + httpUrl: 'http://petstore.swagger.io', }, }, - }; - server.route(new Route('get', '/greet', greetSpec, function greet() {})); + }); const response = await createClientForHandler(server.requestHandler).get( '/swagger-ui', @@ -386,8 +459,6 @@ servers: ].join(''), ); 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'); }); it('supports HTTPS protocol with key and certificate files', async () => { @@ -446,6 +517,34 @@ servers: await server.stop(); }); + // https://github.com/strongloop/loopback-next/issues/1623 + itSkippedOnTravis('handles IPv6 address for API Explorer UI', async () => { + const keyPath = path.join(__dirname, 'key.pem'); + const certPath = path.join(__dirname, '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://loopback.io/api-explorer?url=https://[::1]:58470/openapi.json + const res = await httpsGetAsync(serverUrl + '/swagger-ui'); + const location = res.headers['location']; + expect(location).to.match(/\[\:\:1\]\:\d+\/openapi.json/); + expect(location).to.equal( + `https://loopback.io/api-explorer?url=${serverUrl}/openapi.json`, + ); + await server.stop(); + }); + it('honors HTTPS config binding after instantiation', async () => { const keyPath = path.join(__dirname, 'key.pem'); const certPath = path.join(__dirname, 'cert.pem');