Skip to content

Commit ade46d8

Browse files
committed
feat: introduce app.mountExpressRouter() API
Allow LB4 projects to mount a set of legacy Express routes, for example a legacy LB3 application.
1 parent bb2483a commit ade46d8

File tree

3 files changed

+129
-57
lines changed

3 files changed

+129
-57
lines changed

examples/lb3app/src/application.ts

Lines changed: 28 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@ import {BootMixin} from '@loopback/boot';
77
import {ApplicationConfig} from '@loopback/core';
88
import {RepositoryMixin} from '@loopback/repository';
99
import {
10-
RestApplication,
1110
OpenAPIObject,
12-
OperationObject,
13-
RestServer,
11+
rebaseOpenApiSpec,
12+
RestApplication,
1413
} from '@loopback/rest';
1514
import {RestExplorerComponent} from '@loopback/rest-explorer';
1615
import * as path from 'path';
@@ -56,59 +55,34 @@ export class TodoListApplication extends BootMixin(
5655
const result = await swagger2openapi.convertObj(swaggerSpec, {
5756
// swagger2openapi options
5857
});
59-
6058
const openApiSpec: OpenAPIObject = result.openapi;
6159

62-
// Normalize endpoint paths (if needed)
63-
const basePath = swaggerSpec.basePath;
64-
const hasBasePath = basePath && basePath !== '/';
65-
const servers = openApiSpec.servers || [];
66-
const firstServer = servers[0] || {};
67-
if (hasBasePath && firstServer.url === basePath) {
68-
// move the basePath from server url to endpoint paths
69-
const oldPaths = openApiSpec.paths;
70-
openApiSpec.paths = {};
71-
for (const p in oldPaths)
72-
openApiSpec.paths[`${basePath}${p}`] = oldPaths[p];
73-
}
74-
75-
// Setup dummy route handler function - needed by LB4
76-
for (const p in openApiSpec.paths) {
77-
for (const v in openApiSpec.paths[p]) {
78-
const spec: OperationObject = openApiSpec.paths[p][v];
79-
if (!spec.responses) {
80-
// not an operation object
81-
// paths can have extra properties, e.g. "parameters"
82-
// in addition to operations mapped to HTTP verbs
83-
continue;
84-
}
85-
spec['x-operation'] = function noop() {
86-
const msg =
87-
`The endpoint "${v} ${p}" is a LoopBack v3 route ` +
88-
'handled by the compatibility layer.';
89-
return Promise.reject(new Error(msg));
90-
};
91-
}
92-
}
93-
94-
this.api(openApiSpec);
95-
96-
// A super-hacky way how to mount LB3 app as an express route
97-
// Obviously, we need to find a better solution - a generic extension point
98-
// provided by REST API layer.
99-
// tslint:disable-next-line:no-any
100-
(this.restServer as any)._setupPreprocessingMiddleware = function(
101-
this: RestServer,
102-
) {
103-
// call the original implementation
104-
Object.getPrototypeOf(this)._setupPreprocessingMiddleware.apply(
105-
this,
106-
arguments,
107-
);
108-
109-
// Add our additional middleware
110-
this._expressApp.use(legacyApp);
111-
};
60+
// Option A: mount the entire LB3 app, including any request-preprocessing
61+
// middleware like CORS, Helmet, loopback#token, etc.
62+
63+
// 1. Rebase the spec, e.g. from `GET /Products` to `GET /api/Products`.
64+
const specInRoot = rebaseOpenApiSpec(openApiSpec, swaggerSpec.basePath);
65+
// 2. Mount the full Express app
66+
this.mountExpressRouter('/', specInRoot, legacyApp);
67+
68+
/* Options B: mount LB3 REST handler only.
69+
* Important! This does not mount `loopback#token` middleware!
70+
71+
this.mountExpressRouter(
72+
'/api', // we can use any value here,
73+
// no need to call legacyApp.get('restApiRoot')
74+
openApiSpec,
75+
// TODO(bajtos) reload the handler when a model/method was added/removed
76+
legacyApp.handler('rest')
77+
);
78+
*/
79+
80+
// TODO(bajtos) Listen for the following events to update the OpenAPI spec:
81+
// - modelRemoted
82+
// - modelDeleted
83+
// - remoteMethodAdded
84+
// - remoteMethodDisabled
85+
// Note: LB4 does not support live spec updates yet.
11286

11387
// Boot the new LB4 layer now
11488
return super.boot();

packages/rest/src/rest.application.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {Binding, Constructor, BindingAddress} from '@loopback/context';
6+
import {Binding, BindingAddress, Constructor} from '@loopback/context';
77
import {Application, ApplicationConfig, Server} from '@loopback/core';
88
import {OpenApiSpec, OperationObject} from '@loopback/openapi-v3-types';
99
import {PathParams} from 'express-serve-static-core';
1010
import {ServeStaticOptions} from 'serve-static';
1111
import {format} from 'util';
12+
import {BodyParser} from './body-parsers';
1213
import {RestBindings} from './keys';
1314
import {RestComponent} from './rest.component';
14-
import {HttpRequestListener, HttpServerLike, RestServer} from './rest.server';
15+
import {
16+
ExpressRequestHandler,
17+
HttpRequestListener,
18+
HttpServerLike,
19+
RestServer,
20+
RouterSpec,
21+
} from './rest.server';
1522
import {ControllerClass, ControllerFactory, RouteEntry} from './router';
1623
import {SequenceFunction, SequenceHandler} from './sequence';
17-
import {BodyParser} from './body-parsers';
1824

1925
export const ERR_NO_MULTI_SERVER = format(
2026
'RestApplication does not support multiple servers!',
@@ -242,4 +248,12 @@ export class RestApplication extends Application implements HttpServerLike {
242248
api(spec: OpenApiSpec): Binding {
243249
return this.bind(RestBindings.API_SPEC).to(spec);
244250
}
251+
252+
mountExpressRouter(
253+
basePath: string,
254+
spec: RouterSpec,
255+
router: ExpressRequestHandler,
256+
): void {
257+
this.restServer.mountExpressRouter(basePath, spec, router);
258+
}
245259
}

packages/rest/src/rest.server.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ const SequenceActions = RestBindings.SequenceActions;
7373
// a non-module entity and cannot be imported using this construct.
7474
const cloneDeep: <T>(value: T) => T = require('lodash/cloneDeep');
7575

76+
export type RouterSpec = Pick<OpenApiSpec, 'paths' | 'components' | 'tags'>;
77+
export type ExpressRequestHandler = express.RequestHandler;
78+
7679
/**
7780
* A REST API server for use with Loopback.
7881
* Add this server to your application by importing the RestComponent.
@@ -143,6 +146,8 @@ export class RestServer extends Context implements Server, HttpServerLike {
143146
protected _httpServer: HttpServer | undefined;
144147

145148
protected _expressApp: express.Application;
149+
protected _additionalExpressRoutes: express.Router;
150+
protected _specForAdditionalExpressRoutes: RouterSpec;
146151

147152
get listening(): boolean {
148153
return this._httpServer ? this._httpServer.listening : false;
@@ -198,6 +203,9 @@ export class RestServer extends Context implements Server, HttpServerLike {
198203

199204
this.bind(RestBindings.BASE_PATH).toDynamicValue(() => this._basePath);
200205
this.bind(RestBindings.HANDLER).toDynamicValue(() => this.httpHandler);
206+
207+
this._additionalExpressRoutes = express.Router();
208+
this._specForAdditionalExpressRoutes = {paths: {}};
201209
}
202210

203211
protected _setupRequestHandlerIfNeeded() {
@@ -216,6 +224,12 @@ export class RestServer extends Context implements Server, HttpServerLike {
216224
this._handleHttpRequest(req, res).catch(next);
217225
});
218226

227+
// Mount router for additional Express routes
228+
// FIXME: this will not work!
229+
// 1) we will get invoked after static assets
230+
// 2) errors are not routed to `reject` sequence action
231+
this._expressApp.use(this._basePath, this._additionalExpressRoutes);
232+
219233
// Mount our error handler
220234
this._expressApp.use(
221235
(err: Error, req: Request, res: Response, next: Function) => {
@@ -685,6 +699,9 @@ export class RestServer extends Context implements Server, HttpServerLike {
685699
spec.components = spec.components || {};
686700
spec.components.schemas = cloneDeep(defs);
687701
}
702+
703+
assignRouterSpec(spec, this._specForAdditionalExpressRoutes);
704+
688705
return spec;
689706
}
690707

@@ -831,6 +848,73 @@ export class RestServer extends Context implements Server, HttpServerLike {
831848
throw err;
832849
});
833850
}
851+
852+
/**
853+
* Mount an Express router to expose additional REST endpoints handled
854+
* via legacy Express-based stack.
855+
*
856+
* @param basePath Path where to mount the router at, e.g. `/` or `/api`.
857+
* @param spec A partial OpenAPI spec describing endpoints provided by the router.
858+
* LoopBack will prepend `basePath` to all endpoints automatically. Use `undefined`
859+
* if you don't want to document the routes.
860+
* @param router The Express router to handle the requests.
861+
*/
862+
mountExpressRouter(
863+
basePath: string,
864+
spec: RouterSpec = {paths: {}},
865+
router: ExpressRequestHandler,
866+
): void {
867+
spec = rebaseOpenApiSpec(spec, basePath);
868+
869+
// Merge OpenAPI specs
870+
assignRouterSpec(this._specForAdditionalExpressRoutes, spec);
871+
872+
// Mount the actual Express router/handler
873+
this._additionalExpressRoutes.use(basePath, router);
874+
}
875+
}
876+
877+
export function assignRouterSpec(target: RouterSpec, additions: RouterSpec) {
878+
if (additions.components && additions.components.schemas) {
879+
if (!target.components) target.components = {};
880+
if (!target.components.schemas) target.components.schemas = {};
881+
Object.assign(target.components.schemas, additions.components.schemas);
882+
}
883+
884+
for (const url in additions.paths) {
885+
if (!(url in target.paths)) target.paths[url] = {};
886+
for (const verbOrKey in additions.paths[url]) {
887+
// routes registered earlier takes precedence
888+
if (verbOrKey in target.paths[url]) continue;
889+
target.paths[url][verbOrKey] = additions.paths[url][verbOrKey];
890+
}
891+
}
892+
893+
if (additions.tags && additions.tags.length > 1) {
894+
if (!target.tags) target.tags = [];
895+
for (const tag of additions.tags) {
896+
// tags defined earlier take precedence
897+
if (target.tags.some(t => t.name === tag.name)) continue;
898+
target.tags.push(tag);
899+
}
900+
}
901+
}
902+
903+
export function rebaseOpenApiSpec<T extends Partial<OpenApiSpec>>(
904+
spec: T,
905+
basePath: string,
906+
): T {
907+
if (!spec.paths) return spec;
908+
if (!basePath || basePath === '/') return spec;
909+
910+
const localPaths = spec.paths;
911+
// Don't modify the spec object provided to us.
912+
spec = Object.assign({}, spec, {paths: {}});
913+
for (const url in spec.paths) {
914+
spec.paths[`${basePath}${url}`] = localPaths[url];
915+
}
916+
917+
return spec;
834918
}
835919

836920
/**

0 commit comments

Comments
 (0)