From 2e9730e51138bd5f9bcf66d3214814cd5cf5a703 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 31 Aug 2018 14:37:38 -0700 Subject: [PATCH] feat(rest): add local UI for api explorer --- packages/explorer/index.d.ts | 2 +- packages/explorer/index.js | 2 +- packages/explorer/package.json | 24 +++---- packages/explorer/src/explorer.component.ts | 46 +++++++++++++ packages/explorer/src/explorer.ts | 5 ++ packages/explorer/src/index.ts | 2 + packages/explorer/src/keys.ts | 21 ++++++ .../test/integration/explorer.integration.ts | 2 +- .../test/integration/rest.integration.ts | 44 ++++++++++++ packages/rest/src/keys.ts | 5 ++ packages/rest/src/rest.server.ts | 67 +++++++++++++++---- 11 files changed, 193 insertions(+), 27 deletions(-) create mode 100644 packages/explorer/src/explorer.component.ts create mode 100644 packages/explorer/src/keys.ts create mode 100644 packages/explorer/test/integration/rest.integration.ts diff --git a/packages/explorer/index.d.ts b/packages/explorer/index.d.ts index 880ff9799b52..26661fd7a05f 100644 --- a/packages/explorer/index.d.ts +++ b/packages/explorer/index.d.ts @@ -3,4 +3,4 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -export * from './dist8'; +export * from './dist'; diff --git a/packages/explorer/index.js b/packages/explorer/index.js index 512820ec0cdb..81d3ba10abdd 100644 --- a/packages/explorer/index.js +++ b/packages/explorer/index.js @@ -3,4 +3,4 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -module.exports = require('@loopback/dist-util').loadDist(__dirname); +module.exports = require('./dist'); diff --git a/packages/explorer/package.json b/packages/explorer/package.json index 710f6fb1c27c..92a41038a16f 100644 --- a/packages/explorer/package.json +++ b/packages/explorer/package.json @@ -6,33 +6,33 @@ "node": ">=8.9" }, "scripts": { - "build:all-dist": "npm run build:dist8 && npm run build:dist10", "build:apidocs": "lb-apidocs", - "build": "lb-tsc", - "build:dist8": "lb-tsc es2017", - "build:dist10": "lb-tsc es2018", + "build": "lb-tsc es2017 --outDir dist --copy-resources", "clean": "lb-clean loopback-explorer*.tgz dist* package api-docs", "pretest": "npm run build", - "integration": "lb-mocha \"DIST/test/integration/**/*.js\"", - "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/integration/**/*.js\"", - "unit": "lb-mocha \"DIST/test/unit/**/*.js\"", + "integration": "lb-mocha \"dist/test/integration/**/*.js\"", + "test": "lb-mocha \"dist/test/unit/**/*.js\" \"dist/test/integration/**/*.js\"", + "unit": "lb-mocha \"dist/test/unit/**/*.js\"", "verify": "npm pack && tar xf loopback-explorer*.tgz && tree package && npm run clean" }, "author": "IBM", "copyright.owner": "IBM Corp.", "license": "MIT", "dependencies": { + "@loopback/context": "^1.0.0", + "@loopback/core": "^1.0.0", + "@loopback/dist-util": "^0.3.6", + "@loopback/rest": "^1.1.0", "@types/express": "^4.16.0", "ejs": "^2.6.1", "serve-static": "^1.13.2", - "swagger-ui-dist": "^3.18.2" + "swagger-ui-dist": "^3.19.0" }, "devDependencies": { - "@loopback/build": "^0.7.1", - "@loopback/dist-util": "^0.3.6", - "@loopback/testlab": "^0.11.5", + "@loopback/build": "^1.0.0", + "@loopback/testlab": "^1.0.0", "@types/ejs": "^2.6.0", - "@types/node": "^10.1.1", + "@types/node": "^10.10.1", "@types/serve-static": "^1.13.2", "express": "^4.16.3" }, diff --git a/packages/explorer/src/explorer.component.ts b/packages/explorer/src/explorer.component.ts new file mode 100644 index 000000000000..f5ddc87f80c2 --- /dev/null +++ b/packages/explorer/src/explorer.component.ts @@ -0,0 +1,46 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/explorer +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject} from '@loopback/context'; +import {Application, Component, CoreBindings} from '@loopback/core'; +import {RestServer} from '@loopback/rest'; +import {ApiExplorerUIOptions, apiExplorerUI} from './explorer'; +import {ExplorerBindings} from './keys'; + +/** + * We have a few options: + * + * 1. The Explorer component contributes an express middleware so that REST + * servers can mount the UI + * + * 2. The Explorer component contributes a route to REST servers + * + * 3. The Explorer component proactively mount itself with the RestApplication + */ +export class ExplorerComponent implements Component { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) private application: Application, + @inject(ExplorerBindings.CONFIG, {optional: true}) + private config: ApiExplorerUIOptions, + ) { + this.init(); + } + + init() { + // FIXME: We should be able to receive the servers via injection + const restServerBindings = this.application.find( + binding => + binding.key.startsWith(CoreBindings.SERVERS) && + binding.valueConstructor === RestServer, + ); + for (const binding of restServerBindings) { + const restServer = this.application.getSync(binding.key); + restServer.bind(ExplorerBindings.MIDDLEWARE).to({ + path: this.config.path || '/explorer', + handler: apiExplorerUI(this.config), + }); + } + } +} diff --git a/packages/explorer/src/explorer.ts b/packages/explorer/src/explorer.ts index eb63a5bc46d2..8acf284ebec8 100644 --- a/packages/explorer/src/explorer.ts +++ b/packages/explorer/src/explorer.ts @@ -30,6 +30,11 @@ export type ApiExplorerUIOptions = { * Options for serve-static middleware */ serveStaticOptions?: serveStatic.ServeStaticOptions; + + /** + * Path for the explorer UI + */ + path?: string; }; /** diff --git a/packages/explorer/src/index.ts b/packages/explorer/src/index.ts index b5570c86dc1a..845bef34e20c 100644 --- a/packages/explorer/src/index.ts +++ b/packages/explorer/src/index.ts @@ -4,3 +4,5 @@ // License text available at https://opensource.org/licenses/MIT export * from './explorer'; +export * from './keys'; +export * from './explorer.component'; diff --git a/packages/explorer/src/keys.ts b/packages/explorer/src/keys.ts new file mode 100644 index 000000000000..475288957764 --- /dev/null +++ b/packages/explorer/src/keys.ts @@ -0,0 +1,21 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/explorer +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BindingKey} from '@loopback/context'; +import {ApiExplorerUIOptions} from './explorer'; +import {Middleware} from '@loopback/rest'; + +/** + * Binding keys used by this component. + */ +export namespace ExplorerBindings { + export const MIDDLEWARE = BindingKey.create( + 'middleware.explorer', + ); + + export const CONFIG = BindingKey.create( + 'explorer.config', + ); +} diff --git a/packages/explorer/test/integration/explorer.integration.ts b/packages/explorer/test/integration/explorer.integration.ts index 665db4019e45..c300544aa5ea 100644 --- a/packages/explorer/test/integration/explorer.integration.ts +++ b/packages/explorer/test/integration/explorer.integration.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {createClientForHandler} from '@loopback/testlab'; -import {apiExplorerUI} from '../../src/explorer'; +import {apiExplorerUI} from '../../'; import * as express from 'express'; import * as path from 'path'; diff --git a/packages/explorer/test/integration/rest.integration.ts b/packages/explorer/test/integration/rest.integration.ts new file mode 100644 index 000000000000..f6797b363fca --- /dev/null +++ b/packages/explorer/test/integration/rest.integration.ts @@ -0,0 +1,44 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/explorer +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT +import {createRestAppClient} from '@loopback/testlab'; +import { + RestServerConfig, + RestComponent, + RestServer, + RestApplication, +} from '@loopback/rest'; +import {Application} from '@loopback/core'; +import {ExplorerComponent, ExplorerBindings} from '../..'; + +describe('API Explorer for REST Server', () => { + let restApp: RestApplication; + + before(async () => { + restApp = await givenRestApp({ + rest: {port: 0}, + }); + }); + + after(async () => { + await restApp.stop(); + }); + + it('exposes "GET /explorer"', async () => { + const test = await createRestAppClient(restApp); + test + .get('/explorer') + .expect(200, /\LoopBack API Explorer<\/title\>/) + .expect('content-type', /text\/html.*/); + }); +}); + +async function givenRestApp(options?: {rest: RestServerConfig}) { + const app = new RestApplication(options); + app.bind(ExplorerBindings.CONFIG).to({}); + // FIXME: Can we mount the ExplorerComponent to a given server? + app.component(ExplorerComponent); + await app.start(); + return app; +} diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index 688fbc3bb710..0ee85448b20d 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -32,6 +32,7 @@ import {HttpProtocol} from '@loopback/http-server'; import * as https from 'https'; import {ErrorWriterOptions} from 'strong-error-handler'; import {RestRouter} from './router'; +import {Application} from 'express'; /** * RestServer-specific bindings @@ -73,6 +74,10 @@ export namespace RestBindings { */ export const ROUTER = BindingKey.create('rest.router'); + /** + * Binding key for express app + */ + export const EXPRESS_APP = BindingKey.create('rest.express.app'); /** * Binding key for setting and injecting Reject action's error handling * options. diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 3e698daad76d..894fa9e52d10 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -192,6 +192,7 @@ export class RestServer extends Context implements Server, HttpServerLike { protected _setupRequestHandler() { this._expressApp = express(); + this.bind(RestBindings.EXPRESS_APP).to(this._expressApp); // Disable express' built-in query parser, we parse queries ourselves // Note that when disabled, express sets query to an empty object, @@ -203,17 +204,8 @@ export class RestServer extends Context implements Server, HttpServerLike { this.requestHandler = this._expressApp; - // Allow CORS support for all endpoints so that users - // can test with online SwaggerUI instance - const corsOptions = this.config.cors || { - origin: '*', - methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', - preflightContinue: false, - optionsSuccessStatus: 204, - maxAge: 86400, - credentials: true, - }; - this._expressApp.use(cors(corsOptions)); + // Enable CORS + this._setupCORS(); // Set up endpoints for OpenAPI spec/ui this._setupOpenApiSpecEndpoints(); @@ -232,7 +224,25 @@ export class RestServer extends Context implements Server, HttpServerLike { } /** - * Mount /openapi.json, /openapi.yaml for specs and /swagger-ui, /explorer + * Set up CORS middleware + */ + protected _setupCORS() { + // Allow CORS support for all endpoints so that users + // can test with online SwaggerUI instance + const corsOptions = this.config.cors || { + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + preflightContinue: false, + optionsSuccessStatus: 204, + maxAge: 86400, + credentials: true, + }; + // Set up CORS + this._expressApp.use(cors(corsOptions)); + } + + /** + * Mount /openapi.json, /openapi.yaml for specs and /swagger-ui, /api-explorer * to redirect to externally hosted API explorer */ protected _setupOpenApiSpecEndpoints() { @@ -259,6 +269,20 @@ export class RestServer extends Context implements Server, HttpServerLike { return this.httpHandler.handleRequest(request, response); } + protected _registerMiddleware() { + for (const b of this.find('middleware.*')) { + const middleware = this.getSync(b.key); + if (!middleware.method) { + this._expressApp.use(middleware.path || '/', middleware.handler); + } else { + this._expressApp[middleware.method]( + middleware.path || '/', + middleware.handler, + ); + } + } + } + protected _setupHandlerIfNeeded() { // TODO(bajtos) support hot-reloading of controllers // after the app started. The idea is to rebuild the HttpHandler @@ -273,6 +297,8 @@ export class RestServer extends Context implements Server, HttpServerLike { const routingTable = new RoutingTable(router); this._httpHandler = new HttpHandler(this, routingTable); + this._registerMiddleware(); + for (const b of this.find('controllers.*')) { const controllerName = b.key.replace(/^controllers\./, ''); const ctor = b.valueConstructor; @@ -844,3 +870,20 @@ export interface RestServerOptions { * @interface RestServerConfig */ export type RestServerConfig = RestServerOptions & HttpServerOptions; + +/** + * Middleware registration entry + */ +export interface Middleware { + method?: + | 'all' + | 'get' + | 'post' + | 'put' + | 'delete' + | 'patch' + | 'options' + | 'head'; + path?: PathParams; + handler: express.RequestHandler; +}