From b2ca7dc6a83fc54b1a0e4603171f30df544ac177 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/package.json | 13 ++-- 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 | 40 ++++++++++++ packages/rest/src/keys.ts | 5 ++ packages/rest/src/rest.server.ts | 65 +++++++++++++++---- 9 files changed, 182 insertions(+), 17 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/package.json b/packages/explorer/package.json index 710f6fb1c27c..8494b60a1526 100644 --- a/packages/explorer/package.json +++ b/packages/explorer/package.json @@ -22,17 +22,20 @@ "copyright.owner": "IBM Corp.", "license": "MIT", "dependencies": { + "@loopback/context": "^0.12.7", + "@loopback/core": "^0.11.7", + "@loopback/dist-util": "^0.3.6", + "@loopback/rest": "^0.22.2", "@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": "^0.7.2", + "@loopback/testlab": "^0.12.1", "@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..ebbf7ca9b2bd --- /dev/null +++ b/packages/explorer/test/integration/rest.integration.ts @@ -0,0 +1,40 @@ +// 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 {createClientForRestServer} from '@loopback/testlab'; +import {RestServerConfig, RestComponent, RestServer} from '@loopback/rest'; +import {Application} from '@loopback/core'; +import {ExplorerComponent, ExplorerBindings} from '../..'; + +describe('API Explorer for REST Server', () => { + let server: RestServer; + + before(async () => { + server = await givenAServer({ + rest: {port: 0}, + }); + }); + + after(async () => { + await server.stop(); + }); + + it('exposes "GET /explorer"', async () => { + const test = await createClientForRestServer(server); + test + .get('/explorer') + .expect(200, /\LoopBack API Explorer<\/title\>/) + .expect('content-type', /text\/html.*/); + }); +}); + +async function givenAServer(options?: {rest: RestServerConfig}) { + const app = new Application(options); + app.component(RestComponent); + app.bind(ExplorerBindings.CONFIG).to({}); + // FIXME: Can we mount the ExplorerComponent to a given server? + app.component(ExplorerComponent); + const server = await app.getServer(RestServer); + return server; +} diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index b2d85b34a3af..a92dd5701440 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -30,6 +30,7 @@ import { import {HttpProtocol} from '@loopback/http-server'; import * as https from 'https'; import {ErrorWriterOptions} from 'strong-error-handler'; +import {Application} from 'express'; /** * RestServer-specific bindings @@ -65,6 +66,10 @@ export namespace RestBindings { * Internal binding key for http-handler */ export const HANDLER = BindingKey.create('rest.handler'); + /** + * 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 a2704ffcc408..81637f56ce1e 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -191,6 +191,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, @@ -202,17 +203,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(); // Place the assets router here before controllers this._setupRouterForStaticAssets(); @@ -233,6 +225,24 @@ export class RestServer extends Context implements Server, HttpServerLike { ); } + /** + * 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)); + } + /** * Set up an express router for all static assets so that middleware for * all directories are invoked at the same phase @@ -272,6 +282,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 @@ -279,6 +303,8 @@ export class RestServer extends Context implements Server, HttpServerLike { // See https://github.com/strongloop/loopback-next/issues/433 if (this._httpHandler) return; + this._registerMiddleware(); + this._httpHandler = new HttpHandler(this); for (const b of this.find('controllers.*')) { const controllerName = b.key.replace(/^controllers\./, ''); @@ -824,3 +850,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; +}