Skip to content

Commit

Permalink
feat: introduce app.mountExpressRouter() API
Browse files Browse the repository at this point in the history
Allow LB4 projects to mount a set of legacy Express routes,
for example a legacy LB3 application.
  • Loading branch information
bajtos committed Feb 7, 2019
1 parent bb2483a commit 0a57cf8
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 57 deletions.
82 changes: 28 additions & 54 deletions examples/lb3app/src/application.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 17 additions & 3 deletions packages/rest/src/rest.application.ts
Expand Up @@ -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!',
Expand Down Expand Up @@ -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);
}
}
81 changes: 81 additions & 0 deletions packages/rest/src/rest.server.ts
Expand Up @@ -73,6 +73,9 @@ const SequenceActions = RestBindings.SequenceActions;
// a non-module entity and cannot be imported using this construct.
const cloneDeep: <T>(value: T) => T = require('lodash/cloneDeep');

export type RouterSpec = Pick<OpenApiSpec, 'paths' | 'components' | 'tags'>;
export type ExpressRequestHandler = express.RequestHandler;

/**
* A REST API server for use with Loopback.
* Add this server to your application by importing the RestComponent.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -216,6 +224,9 @@ export class RestServer extends Context implements Server, HttpServerLike {
this._handleHttpRequest(req, res).catch(next);
});

// Mount router for additional Express routes
this._expressApp.use(this._basePath, this._additionalExpressRoutes);

// Mount our error handler
this._expressApp.use(
(err: Error, req: Request, res: Response, next: Function) => {
Expand Down Expand Up @@ -685,6 +696,9 @@ export class RestServer extends Context implements Server, HttpServerLike {
spec.components = spec.components || {};
spec.components.schemas = cloneDeep(defs);
}

assignRouterSpec(spec, this._specForAdditionalExpressRoutes);

return spec;
}

Expand Down Expand Up @@ -831,6 +845,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<T extends Partial<OpenApiSpec>>(
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;
}

/**
Expand Down

0 comments on commit 0a57cf8

Please sign in to comment.