From ade46d8f6b838ad8ba190498403ead56f6f64296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 7 Feb 2019 10:29:11 +0100 Subject: [PATCH] feat: introduce `app.mountExpressRouter()` API Allow LB4 projects to mount a set of legacy Express routes, for example a legacy LB3 application. --- examples/lb3app/src/application.ts | 82 +++++++++----------------- packages/rest/src/rest.application.ts | 20 ++++++- packages/rest/src/rest.server.ts | 84 +++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 57 deletions(-) diff --git a/examples/lb3app/src/application.ts b/examples/lb3app/src/application.ts index e6084821faa2..ba546c4b6bfa 100644 --- a/examples/lb3app/src/application.ts +++ b/examples/lb3app/src/application.ts @@ -7,10 +7,9 @@ import {BootMixin} from '@loopback/boot'; import {ApplicationConfig} from '@loopback/core'; import {RepositoryMixin} from '@loopback/repository'; import { - RestApplication, OpenAPIObject, - OperationObject, - RestServer, + rebaseOpenApiSpec, + RestApplication, } from '@loopback/rest'; import {RestExplorerComponent} from '@loopback/rest-explorer'; import * as path from 'path'; @@ -56,59 +55,34 @@ export class TodoListApplication extends BootMixin( const result = await swagger2openapi.convertObj(swaggerSpec, { // swagger2openapi options }); - const openApiSpec: OpenAPIObject = result.openapi; - // Normalize endpoint paths (if needed) - const basePath = swaggerSpec.basePath; - const hasBasePath = basePath && basePath !== '/'; - const servers = openApiSpec.servers || []; - const firstServer = servers[0] || {}; - if (hasBasePath && firstServer.url === basePath) { - // move the basePath from server url to endpoint paths - const oldPaths = openApiSpec.paths; - openApiSpec.paths = {}; - for (const p in oldPaths) - openApiSpec.paths[`${basePath}${p}`] = oldPaths[p]; - } - - // Setup dummy route handler function - needed by LB4 - for (const p in openApiSpec.paths) { - for (const v in openApiSpec.paths[p]) { - const spec: OperationObject = openApiSpec.paths[p][v]; - if (!spec.responses) { - // not an operation object - // paths can have extra properties, e.g. "parameters" - // in addition to operations mapped to HTTP verbs - continue; - } - spec['x-operation'] = function noop() { - const msg = - `The endpoint "${v} ${p}" is a LoopBack v3 route ` + - 'handled by the compatibility layer.'; - return Promise.reject(new Error(msg)); - }; - } - } - - this.api(openApiSpec); - - // A super-hacky way how to mount LB3 app as an express route - // Obviously, we need to find a better solution - a generic extension point - // provided by REST API layer. - // tslint:disable-next-line:no-any - (this.restServer as any)._setupPreprocessingMiddleware = function( - this: RestServer, - ) { - // call the original implementation - Object.getPrototypeOf(this)._setupPreprocessingMiddleware.apply( - this, - arguments, - ); - - // Add our additional middleware - this._expressApp.use(legacyApp); - }; + // Option A: mount the entire LB3 app, including any request-preprocessing + // middleware like CORS, Helmet, loopback#token, etc. + + // 1. Rebase the spec, e.g. from `GET /Products` to `GET /api/Products`. + const specInRoot = rebaseOpenApiSpec(openApiSpec, swaggerSpec.basePath); + // 2. Mount the full Express app + this.mountExpressRouter('/', specInRoot, legacyApp); + + /* Options B: mount LB3 REST handler only. + * Important! This does not mount `loopback#token` middleware! + + this.mountExpressRouter( + '/api', // we can use any value here, + // no need to call legacyApp.get('restApiRoot') + openApiSpec, + // TODO(bajtos) reload the handler when a model/method was added/removed + legacyApp.handler('rest') + ); + */ + + // TODO(bajtos) Listen for the following events to update the OpenAPI spec: + // - modelRemoted + // - modelDeleted + // - remoteMethodAdded + // - remoteMethodDisabled + // Note: LB4 does not support live spec updates yet. // Boot the new LB4 layer now return super.boot(); diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index d256158c3550..0526b698197e 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -3,18 +3,24 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Binding, Constructor, BindingAddress} from '@loopback/context'; +import {Binding, BindingAddress, Constructor} from '@loopback/context'; import {Application, ApplicationConfig, Server} from '@loopback/core'; import {OpenApiSpec, OperationObject} from '@loopback/openapi-v3-types'; import {PathParams} from 'express-serve-static-core'; import {ServeStaticOptions} from 'serve-static'; import {format} from 'util'; +import {BodyParser} from './body-parsers'; import {RestBindings} from './keys'; import {RestComponent} from './rest.component'; -import {HttpRequestListener, HttpServerLike, RestServer} from './rest.server'; +import { + ExpressRequestHandler, + HttpRequestListener, + HttpServerLike, + RestServer, + RouterSpec, +} from './rest.server'; import {ControllerClass, ControllerFactory, RouteEntry} from './router'; import {SequenceFunction, SequenceHandler} from './sequence'; -import {BodyParser} from './body-parsers'; export const ERR_NO_MULTI_SERVER = format( 'RestApplication does not support multiple servers!', @@ -242,4 +248,12 @@ export class RestApplication extends Application implements HttpServerLike { api(spec: OpenApiSpec): Binding { return this.bind(RestBindings.API_SPEC).to(spec); } + + mountExpressRouter( + basePath: string, + spec: RouterSpec, + router: ExpressRequestHandler, + ): void { + this.restServer.mountExpressRouter(basePath, spec, router); + } } diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 508e62a8ebb5..476d29347c5c 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -73,6 +73,9 @@ const SequenceActions = RestBindings.SequenceActions; // a non-module entity and cannot be imported using this construct. const cloneDeep: (value: T) => T = require('lodash/cloneDeep'); +export type RouterSpec = Pick; +export type ExpressRequestHandler = express.RequestHandler; + /** * A REST API server for use with Loopback. * Add this server to your application by importing the RestComponent. @@ -143,6 +146,8 @@ export class RestServer extends Context implements Server, HttpServerLike { protected _httpServer: HttpServer | undefined; protected _expressApp: express.Application; + protected _additionalExpressRoutes: express.Router; + protected _specForAdditionalExpressRoutes: RouterSpec; get listening(): boolean { return this._httpServer ? this._httpServer.listening : false; @@ -198,6 +203,9 @@ export class RestServer extends Context implements Server, HttpServerLike { this.bind(RestBindings.BASE_PATH).toDynamicValue(() => this._basePath); this.bind(RestBindings.HANDLER).toDynamicValue(() => this.httpHandler); + + this._additionalExpressRoutes = express.Router(); + this._specForAdditionalExpressRoutes = {paths: {}}; } protected _setupRequestHandlerIfNeeded() { @@ -216,6 +224,12 @@ export class RestServer extends Context implements Server, HttpServerLike { this._handleHttpRequest(req, res).catch(next); }); + // Mount router for additional Express routes + // FIXME: this will not work! + // 1) we will get invoked after static assets + // 2) errors are not routed to `reject` sequence action + this._expressApp.use(this._basePath, this._additionalExpressRoutes); + // Mount our error handler this._expressApp.use( (err: Error, req: Request, res: Response, next: Function) => { @@ -685,6 +699,9 @@ export class RestServer extends Context implements Server, HttpServerLike { spec.components = spec.components || {}; spec.components.schemas = cloneDeep(defs); } + + assignRouterSpec(spec, this._specForAdditionalExpressRoutes); + return spec; } @@ -831,6 +848,73 @@ export class RestServer extends Context implements Server, HttpServerLike { throw err; }); } + + /** + * Mount an Express router to expose additional REST endpoints handled + * via legacy Express-based stack. + * + * @param basePath Path where to mount the router at, e.g. `/` or `/api`. + * @param spec A partial OpenAPI spec describing endpoints provided by the router. + * LoopBack will prepend `basePath` to all endpoints automatically. Use `undefined` + * if you don't want to document the routes. + * @param router The Express router to handle the requests. + */ + mountExpressRouter( + basePath: string, + spec: RouterSpec = {paths: {}}, + router: ExpressRequestHandler, + ): void { + spec = rebaseOpenApiSpec(spec, basePath); + + // Merge OpenAPI specs + assignRouterSpec(this._specForAdditionalExpressRoutes, spec); + + // Mount the actual Express router/handler + this._additionalExpressRoutes.use(basePath, router); + } +} + +export function assignRouterSpec(target: RouterSpec, additions: RouterSpec) { + if (additions.components && additions.components.schemas) { + if (!target.components) target.components = {}; + if (!target.components.schemas) target.components.schemas = {}; + Object.assign(target.components.schemas, additions.components.schemas); + } + + for (const url in additions.paths) { + if (!(url in target.paths)) target.paths[url] = {}; + for (const verbOrKey in additions.paths[url]) { + // routes registered earlier takes precedence + if (verbOrKey in target.paths[url]) continue; + target.paths[url][verbOrKey] = additions.paths[url][verbOrKey]; + } + } + + if (additions.tags && additions.tags.length > 1) { + if (!target.tags) target.tags = []; + for (const tag of additions.tags) { + // tags defined earlier take precedence + if (target.tags.some(t => t.name === tag.name)) continue; + target.tags.push(tag); + } + } +} + +export function rebaseOpenApiSpec>( + spec: T, + basePath: string, +): T { + if (!spec.paths) return spec; + if (!basePath || basePath === '/') return spec; + + const localPaths = spec.paths; + // Don't modify the spec object provided to us. + spec = Object.assign({}, spec, {paths: {}}); + for (const url in spec.paths) { + spec.paths[`${basePath}${url}`] = localPaths[url]; + } + + return spec; } /**