diff --git a/packages/explorer/.npmrc b/packages/explorer/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/explorer/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/explorer/LICENSE b/packages/explorer/LICENSE new file mode 100644 index 000000000000..3d49daf70323 --- /dev/null +++ b/packages/explorer/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/explorer +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/explorer/README.md b/packages/explorer/README.md new file mode 100644 index 000000000000..86337fb86cad --- /dev/null +++ b/packages/explorer/README.md @@ -0,0 +1,27 @@ +# @loopback/explorer + +This module contains a handler to serve API Explorer + +## Installation + +```sh +npm install --save @loopback/explorer +``` + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/explorer/docs.json b/packages/explorer/docs.json new file mode 100644 index 000000000000..81a57a650369 --- /dev/null +++ b/packages/explorer/docs.json @@ -0,0 +1,7 @@ +{ + "content": [ + "index.ts", + "src/explorer.ts" + ], + "codeSectionDepth": 4 +} diff --git a/packages/explorer/index.d.ts b/packages/explorer/index.d.ts new file mode 100644 index 000000000000..26661fd7a05f --- /dev/null +++ b/packages/explorer/index.d.ts @@ -0,0 +1,6 @@ +// 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 + +export * from './dist'; diff --git a/packages/explorer/index.js b/packages/explorer/index.js new file mode 100644 index 000000000000..81d3ba10abdd --- /dev/null +++ b/packages/explorer/index.js @@ -0,0 +1,6 @@ +// 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 + +module.exports = require('./dist'); diff --git a/packages/explorer/index.ts b/packages/explorer/index.ts new file mode 100644 index 000000000000..3a66043229fa --- /dev/null +++ b/packages/explorer/index.ts @@ -0,0 +1,8 @@ +// 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 + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/explorer/package.json b/packages/explorer/package.json new file mode 100644 index 000000000000..92a41038a16f --- /dev/null +++ b/packages/explorer/package.json @@ -0,0 +1,59 @@ +{ + "name": "@loopback/explorer", + "version": "0.1.0", + "description": "LoopBack's API Explorer", + "engines": { + "node": ">=8.9" + }, + "scripts": { + "build:apidocs": "lb-apidocs", + "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\"", + "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.19.0" + }, + "devDependencies": { + "@loopback/build": "^1.0.0", + "@loopback/testlab": "^1.0.0", + "@types/ejs": "^2.6.0", + "@types/node": "^10.10.1", + "@types/serve-static": "^1.13.2", + "express": "^4.16.3" + }, + "keywords": [ + "LoopBack", + "Explorer", + "Swagger" + ], + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist*/src", + "dist*/index*", + "src" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} 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 new file mode 100644 index 000000000000..8acf284ebec8 --- /dev/null +++ b/packages/explorer/src/explorer.ts @@ -0,0 +1,69 @@ +// 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 + +const swaggerUI = require('swagger-ui-dist'); + +import * as path from 'path'; +import * as fs from 'fs'; + +import {Handler} from 'express'; +import * as serveStatic from 'serve-static'; +import * as ejs from 'ejs'; + +/** + * Options to configure API Explorer UI + */ +export type ApiExplorerUIOptions = { + /** + * URL to the OpenAPI spec + */ + openApiSpecUrl?: string; + + /** + * Custom EJS template for index.html + */ + indexHtmlTemplate?: string; + + /** + * Options for serve-static middleware + */ + serveStaticOptions?: serveStatic.ServeStaticOptions; + + /** + * Path for the explorer UI + */ + path?: string; +}; + +/** + * Mount the API Explorer UI (swagger-ui) to the given express router + * @param options + */ +export function apiExplorerUI(options: ApiExplorerUIOptions = {}): Handler { + const openApiSpecUrl = options.openApiSpecUrl || '/openapi.json'; + const indexHtml = + options.indexHtmlTemplate || path.resolve(__dirname, './index.html.ejs'); + const template = fs.readFileSync(indexHtml, 'utf-8'); + const templateFn = ejs.compile(template); + const uiHandler = serveStatic( + swaggerUI.getAbsoluteFSPath(), + options.serveStaticOptions, + ); + + return (req, res, next) => { + if (req.path === '/' || req.path === '/index.html') { + const data = { + openApiSpecUrl, + }; + const homePage = templateFn(data); + res + .status(200) + .contentType('text/html') + .send(homePage); + } else { + uiHandler(req, res, next); + } + }; +} diff --git a/packages/explorer/src/index.html.ejs b/packages/explorer/src/index.html.ejs new file mode 100644 index 000000000000..28ceaf371838 --- /dev/null +++ b/packages/explorer/src/index.html.ejs @@ -0,0 +1,64 @@ + + + + + + + LoopBack API Explorer + + + + + + + +
+ + + + + + + diff --git a/packages/explorer/src/index.ts b/packages/explorer/src/index.ts new file mode 100644 index 000000000000..845bef34e20c --- /dev/null +++ b/packages/explorer/src/index.ts @@ -0,0 +1,8 @@ +// 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 + +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 new file mode 100644 index 000000000000..c300544aa5ea --- /dev/null +++ b/packages/explorer/test/integration/explorer.integration.ts @@ -0,0 +1,57 @@ +// 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 {createClientForHandler} from '@loopback/testlab'; +import {apiExplorerUI} from '../../'; +import * as express from 'express'; +import * as path from 'path'; + +describe('API Explorer UI', () => { + let app: express.Application; + let router: express.Router; + + beforeEach(() => { + app = express(); + router = express.Router(); + app.use('/api-explorer', router); + }); + + it('mounts API explorer UI', () => { + router.use(apiExplorerUI()); + const test = createClientForHandler(app); + test + .get('/api-explorer') + .expect('content-type', 'text/html') + .expect(200, 'LoopBack API Explorer'); + + test.get('/api-explorer/swagger-ui-bundle.js').expect(200); + }); + + it('accepts API explorer UI options - openApiSpecUrl', () => { + router.use( + apiExplorerUI({ + openApiSpecUrl: 'https://localhost:8080/openapi.json', + }), + ); + const test = createClientForHandler(app); + test + .get('/api-explorer') + .expect('content-type', 'text/html') + .expect(200, 'https://localhost:8080/openapi.json'); + }); + + it('accepts API explorer UI options - indexHtmlTemplate', () => { + router.use( + apiExplorerUI({ + indexHtmlTemplate: path.resolve(__dirname, 'test.html.ejs'), + }), + ); + const test = createClientForHandler(app); + test + .get('/api-explorer') + .expect('content-type', 'text/html') + .expect(200, 'Test API Explorer'); + }); +}); diff --git a/packages/explorer/test/integration/rest.integration.ts b/packages/explorer/test/integration/rest.integration.ts new file mode 100644 index 000000000000..1fecb49700f1 --- /dev/null +++ b/packages/explorer/test/integration/rest.integration.ts @@ -0,0 +1,38 @@ +// 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, RestApplication} from '@loopback/rest'; +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"', () => { + const test = createRestAppClient(restApp); + return 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/explorer/test/integration/test.html.ejs b/packages/explorer/test/integration/test.html.ejs new file mode 100644 index 000000000000..e2b271528f41 --- /dev/null +++ b/packages/explorer/test/integration/test.html.ejs @@ -0,0 +1,65 @@ + + + + + + + Test API Explorer + + + + + + + +
+ + + + + + + diff --git a/packages/explorer/tsconfig.build.json b/packages/explorer/tsconfig.build.json new file mode 100644 index 000000000000..3ffcd508d23e --- /dev/null +++ b/packages/explorer/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["index.ts", "src", "test"] +} 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; +}