diff --git a/docs/site/Routes.md b/docs/site/Routes.md index 0bb64bec2b24..df4120d04f96 100644 --- a/docs/site/Routes.md +++ b/docs/site/Routes.md @@ -226,3 +226,64 @@ export class MyApplication extends RestApplication { } } ``` + +## Mounting an Express Router + +If you have an existing [Express](https://expressjs.com/) application that you +want to use with LoopBack 4, you can mount the Express application on top of a +LoopBack 4 application. This way you can mix and match both frameworks, while +using LoopBack as the host. You can also do the opposite and use Express as the +host by mounting LoopBack 4 REST API on an Express application. See +[Creating an Express Application with LoopBack REST API](express-with-lb4-rest-tutorial.md) +for the tutorial. + +Mounting an Express router on a LoopBack 4 application can be done using the +`mountExpressRouter` function provided by both +[`RestApplication`](http://apidocs.loopback.io/@loopback%2fdocs/rest.html#RestApplication) +and +[`RestServer`](http://apidocs.loopback.io/@loopback%2fdocs/rest.html#RestServer). + +Example use: + +{% include note.html content=" +Make sure [express](https://www.npmjs.com/package/express) is installed. +" %} + +{% include code-caption.html content="src/express-app.ts" %} + +```ts +import {Request, Response} from 'express'; +import * as express from 'express'; + +const legacyApp = express(); + +// your existing Express routes +legacyApp.get('/pug', function(_req: Request, res: Response) { + res.send('Pug!'); +}); + +export {legacyApp}; +``` + +{% include code-caption.html content="src/application.ts" %} + +```ts +import {RestApplication} from '@loopback/rest'; + +const legacyApp = require('./express-app').legacyApp; + +const openApiSpecForLegacyApp: RouterSpec = { + // insert your spec here, your 'paths', 'components', and 'tags' will be used +}; + +class MyApplication extends RestApplication { + constructor(/* ... */) { + // ... + + this.mountExpressRouter('/dogs', legacyApp, openApiSpecForLegacyApp); + } +} +``` + +Any routes you define in your `legacyApp` will be mounted on top of the `/dogs` +base path, e.g. if you visit the `/dogs/pug` endpoint, you'll see `Pug!`. diff --git a/docs/site/express-with-lb4-rest-tutorial.md b/docs/site/express-with-lb4-rest-tutorial.md index 3fbe26dfb247..64636a21227a 100644 --- a/docs/site/express-with-lb4-rest-tutorial.md +++ b/docs/site/express-with-lb4-rest-tutorial.md @@ -14,6 +14,12 @@ REST API can be mounted to an Express application and be used as middleware. This way the user can mix and match features from both frameworks to suit their needs. +{% include note.html content=" +If you want to use LoopBack as the host instead and mount your Express +application on a LoopBack 4 application, see +[Mounting an Express Router](Routes.md#mounting-an-express-router). +" %} + This tutorial assumes familiarity with scaffolding a LoopBack 4 application, [`Models`](Model.md), [`DataSources`](DataSources.md), [`Repositories`](Repositories.md), and [`Controllers`](Controllers.md). To see diff --git a/packages/rest/src/__tests__/integration/rest.application.integration.ts b/packages/rest/src/__tests__/integration/rest.application.integration.ts index 5f7dc3bd9d01..8ed4d5097d12 100644 --- a/packages/rest/src/__tests__/integration/rest.application.integration.ts +++ b/packages/rest/src/__tests__/integration/rest.application.integration.ts @@ -5,9 +5,17 @@ import {anOperationSpec} from '@loopback/openapi-spec-builder'; import {Client, createRestAppClient, expect} from '@loopback/testlab'; +import * as express from 'express'; +import {Request, Response} from 'express'; import * as fs from 'fs'; import * as path from 'path'; -import {RestApplication, RestServer, RestServerConfig, get} from '../..'; +import { + get, + RestApplication, + RestServer, + RestServerConfig, + RouterSpec, +} from '../..'; const ASSETS = path.resolve(__dirname, '../../../fixtures/assets'); @@ -163,6 +171,103 @@ describe('RestApplication (integration)', () => { await client.get(response.header.location).expect(200, 'Hi'); }); + context('mounting an Express router on a LoopBack application', async () => { + beforeEach('set up RestApplication', async () => { + givenApplication(); + await restApp.start(); + client = createRestAppClient(restApp); + }); + + it('gives precedence to an external route over a static route', async () => { + const router = express.Router(); + router.get('/', function(_req: Request, res: Response) { + res.send('External dog'); + }); + + restApp.static('/dogs', ASSETS); + restApp.mountExpressRouter('/dogs', router); + + await client.get('/dogs/').expect(200, 'External dog'); + }); + + it('mounts an express Router without spec', async () => { + const router = express.Router(); + router.get('/poodle/', function(_req: Request, res: Response) { + res.send('Poodle!'); + }); + router.get('/pug', function(_req: Request, res: Response) { + res.send('Pug!'); + }); + restApp.mountExpressRouter('/dogs', router); + + await client.get('/dogs/poodle/').expect(200, 'Poodle!'); + await client.get('/dogs/pug').expect(200, 'Pug!'); + }); + + it('mounts an express Router with spec', async () => { + const router = express.Router(); + function greetDogs(_req: Request, res: Response) { + res.send('Hello dogs!'); + } + + const spec: RouterSpec = { + paths: { + '/hello': { + get: { + responses: { + '200': { + description: 'greet the dogs', + content: { + 'text/plain': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }; + router.get('/hello', greetDogs); + restApp.mountExpressRouter('/dogs', router, spec); + await client.get('/dogs/hello').expect(200, 'Hello dogs!'); + + const openApiSpec = restApp.restServer.getApiSpec(); + expect(openApiSpec.paths).to.deepEqual({ + '/dogs/hello': { + get: { + responses: { + '200': { + description: 'greet the dogs', + content: {'text/plain': {schema: {type: 'string'}}}, + }, + }, + }, + }, + }); + }); + + it('mounts more than one express Router', async () => { + const router = express.Router(); + router.get('/poodle', function(_req: Request, res: Response) { + res.send('Poodle!'); + }); + + restApp.mountExpressRouter('/dogs', router); + + const secondRouter = express.Router(); + + secondRouter.get('/persian', function(_req: Request, res: Response) { + res.send('Persian cat.'); + }); + + restApp.mountExpressRouter('/cats', secondRouter); + + await client.get('/dogs/poodle').expect(200, 'Poodle!'); + await client.get('/cats/persian').expect(200, 'Persian cat.'); + }); + }); + function givenApplication(options?: {rest: RestServerConfig}) { options = options || {rest: {port: 0, host: '127.0.0.1'}}; restApp = new RestApplication(options); diff --git a/packages/rest/src/__tests__/unit/router/assign-router-spec.unit.ts b/packages/rest/src/__tests__/unit/router/assign-router-spec.unit.ts new file mode 100644 index 000000000000..95dff1f931f3 --- /dev/null +++ b/packages/rest/src/__tests__/unit/router/assign-router-spec.unit.ts @@ -0,0 +1,151 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {assignRouterSpec, RouterSpec} from '../../../'; + +describe('assignRouterSpec', () => { + it('duplicates the additions spec if the target spec is empty', async () => { + const target: RouterSpec = {paths: {}}; + const additions: RouterSpec = { + paths: { + '/': { + get: { + responses: { + '200': { + description: 'greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Greeting: { + type: 'object', + properties: { + message: { + type: 'string', + }, + }, + }, + }, + }, + tags: [{name: 'greeting', description: 'greetings'}], + }; + + assignRouterSpec(target, additions); + expect(target).to.eql(additions); + }); + + it('does not assign components without schema', async () => { + const target: RouterSpec = { + paths: {}, + components: {}, + }; + + const additions: RouterSpec = { + paths: {}, + components: { + parameters: { + addParam: { + name: 'add', + in: 'query', + description: 'number of items to add', + required: true, + schema: { + type: 'integer', + format: 'int32', + }, + }, + }, + responses: { + Hello: { + description: 'Hello.', + }, + }, + }, + }; + + assignRouterSpec(target, additions); + expect(target.components).to.be.empty(); + }); + + it('uses the route registered first', async () => { + const originalPath = { + '/': { + get: { + responses: { + '200': { + description: 'greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }; + + const target: RouterSpec = {paths: originalPath}; + + const additions: RouterSpec = { + paths: { + '/': { + get: { + responses: { + '200': { + description: 'additional greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + '404': { + description: 'Error: no greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }; + + assignRouterSpec(target, additions); + expect(target.paths).to.eql(originalPath); + }); + + it('does not duplicate tags from the additional spec', async () => { + const target: RouterSpec = { + paths: {}, + tags: [{name: 'greeting', description: 'greetings'}], + }; + const additions: RouterSpec = { + paths: {}, + tags: [ + {name: 'greeting', description: 'additional greetings'}, + {name: 'salutation', description: 'salutations!'}, + ], + }; + + assignRouterSpec(target, additions); + expect(target.tags).to.containDeep([ + {name: 'greeting', description: 'greetings'}, + {name: 'salutation', description: 'salutations!'}, + ]); + }); +}); diff --git a/packages/rest/src/__tests__/unit/router/rebase-openapi-spec.unit.ts b/packages/rest/src/__tests__/unit/router/rebase-openapi-spec.unit.ts new file mode 100644 index 000000000000..59f318db3aa3 --- /dev/null +++ b/packages/rest/src/__tests__/unit/router/rebase-openapi-spec.unit.ts @@ -0,0 +1,144 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {rebaseOpenApiSpec} from '../../../router'; + +describe('rebaseOpenApiSpec', () => { + it('does not modify an OpenAPI spec if it does not have paths', async () => { + const spec = { + title: 'Greetings', + components: { + responses: { + Hello: { + description: 'Hello.', + }, + }, + }, + tags: [{name: 'greeting', description: 'greetings'}], + }; + const rebasedSpec = rebaseOpenApiSpec(spec, '/api'); + + expect(rebasedSpec).to.eql(spec); + }); + + it('does not modify the OpenApiSpec if basePath is empty or `/`', async () => { + const spec = { + paths: { + '/': { + get: { + responses: { + '200': { + description: 'greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + '/hello': { + post: { + summary: 'says hello', + consumes: 'application/json', + responses: { + '200': { + description: 'OK', + }, + }, + }, + }, + }, + }; + + let rebasedSpec = rebaseOpenApiSpec(spec, ''); + expect(spec).to.eql(rebasedSpec); + + rebasedSpec = rebaseOpenApiSpec(spec, '/'); + expect(rebasedSpec).to.eql(spec); + }); + + it('rebases OpenApiSpec if there is a basePath', async () => { + const spec = { + paths: { + '/': { + get: { + responses: { + '200': { + description: 'greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + '/hello': { + post: { + responses: { + '200': { + description: 'greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }; + + const rebasedSpec = rebaseOpenApiSpec(spec, '/greetings'); + const rebasedPaths = Object.keys(rebasedSpec.paths); + + expect(rebasedPaths).to.eql(['/greetings/', '/greetings/hello']); + }); + + it('does not modify the original OpenApiSpec', async () => { + const spec = { + paths: { + '/': { + get: { + responses: { + '200': { + description: 'greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + '/hello': { + post: { + responses: { + '200': { + description: 'greeting', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }; + + rebaseOpenApiSpec(spec, '/greetings'); + + const specPaths = Object.keys(spec.paths); + expect(specPaths).to.deepEqual(['/', '/hello']); + }); +}); diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index 70d26e8272bd..05c34e390d78 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 {ControllerClass, ControllerFactory, RouteEntry} from './router'; +import { + ControllerClass, + ControllerFactory, + ExpressRequestHandler, + RouteEntry, +} from './router'; +import {RouterSpec} from './router/router-spec'; import {SequenceFunction, SequenceHandler} from './sequence'; -import {BodyParser} from './body-parsers'; export const ERR_NO_MULTI_SERVER = format( 'RestApplication does not support multiple servers!', @@ -264,4 +270,23 @@ export class RestApplication extends Application implements HttpServerLike { api(spec: OpenApiSpec): Binding { return this.bind(RestBindings.API_SPEC).to(spec); } + + /** + * 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 router The Express router to handle the requests. + * @param spec A partial OpenAPI spec describing endpoints provided by the + * router. LoopBack will prepend `basePath` to all endpoints automatically. + * This argument is optional. You can leave it out if you don't want to + * document the routes. + */ + mountExpressRouter( + basePath: string, + router: ExpressRequestHandler, + spec?: RouterSpec, + ): void { + this.restServer.mountExpressRouter(basePath, router, spec); + } } diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 007f9420a601..f63603b7330d 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -38,13 +38,16 @@ import { ControllerInstance, ControllerRoute, createControllerFactoryForBinding, + ExpressRequestHandler, ExternalExpressRoutes, RedirectRoute, RestRouterOptions, Route, RouteEntry, + RouterSpec, RoutingTable, } from './router'; +import {assignRouterSpec} from './router/router-spec'; import {DefaultSequence, SequenceFunction, SequenceHandler} from './sequence'; import { FindRoute, @@ -681,6 +684,8 @@ export class RestServer extends Context implements Server, HttpServerLike { spec.components = spec.components || {}; spec.components.schemas = cloneDeep(defs); } + + assignRouterSpec(spec, this._externalRoutes.routerSpec); return spec; } @@ -827,6 +832,25 @@ 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 router The Express router to handle the requests. + * @param spec A partial OpenAPI spec describing endpoints provided by the + * router. LoopBack will prepend `basePath` to all endpoints automatically. + * This argument is optional. You can leave it out if you don't want to + * document the routes. + */ + mountExpressRouter( + basePath: string, + router: ExpressRequestHandler, + spec?: RouterSpec, + ): void { + this._externalRoutes.mountRouter(basePath, router, spec); + } } /** diff --git a/packages/rest/src/router/external-express-routes.ts b/packages/rest/src/router/external-express-routes.ts index dba6216a0f92..2e3ffcb1b4d7 100644 --- a/packages/rest/src/router/external-express-routes.ts +++ b/packages/rest/src/router/external-express-routes.ts @@ -4,7 +4,11 @@ // License text available at https://opensource.org/licenses/MIT import {Context} from '@loopback/context'; -import {OperationObject, SchemasObject} from '@loopback/openapi-v3-types'; +import { + OpenApiSpec, + OperationObject, + SchemasObject, +} from '@loopback/openapi-v3-types'; import * as express from 'express'; import {RequestHandler} from 'express'; import {PathParams} from 'express-serve-static-core'; @@ -21,6 +25,7 @@ import { Response, } from '../types'; import {ResolvedRoute, RouteEntry} from './route-entry'; +import {assignRouterSpec, RouterSpec} from './router-spec'; export type ExpressRequestHandler = express.RequestHandler; @@ -32,7 +37,13 @@ export type ExpressRequestHandler = express.RequestHandler; * @private */ export class ExternalExpressRoutes { + protected _externalRoutes: express.Router = express.Router(); protected _staticRoutes: express.Router = express.Router(); + protected _specForExternalRoutes: RouterSpec = {paths: {}}; + + get routerSpec(): RouterSpec { + return this._specForExternalRoutes; + } public registerAssets( path: PathParams, @@ -42,12 +53,29 @@ export class ExternalExpressRoutes { this._staticRoutes.use(path, express.static(rootDir, options)); } + public mountRouter( + basePath: string, + router: ExpressRequestHandler, + spec: RouterSpec = {paths: {}}, + ) { + this._externalRoutes.use(basePath, router); + + spec = rebaseOpenApiSpec(spec, basePath); + assignRouterSpec(this._specForExternalRoutes, spec); + } + find(request: Request): ResolvedRoute { - return new ExternalRoute(this._staticRoutes, request.method, request.url, { - description: 'LoopBack static assets route', - 'x-visibility': 'undocumented', - responses: {}, - }); + return new ExternalRoute( + this._externalRoutes, + this._staticRoutes, + request.method, + request.url, + { + description: 'External route or a static asset', + 'x-visibility': 'undocumented', + responses: {}, + }, + ); } } @@ -57,6 +85,7 @@ class ExternalRoute implements RouteEntry, ResolvedRoute { readonly schemas: SchemasObject = {}; constructor( + private readonly _externalRouter: express.Router, private readonly _staticAssets: express.Router, public readonly verb: string, public readonly path: string, @@ -71,7 +100,14 @@ class ExternalRoute implements RouteEntry, ResolvedRoute { {request, response}: RequestContext, args: OperationArgs, ): Promise { - const handled = await executeRequestHandler( + let handled = await executeRequestHandler( + this._externalRouter, + request, + response, + ); + if (handled) return; + + handled = await executeRequestHandler( this._staticAssets, request, response, @@ -90,6 +126,24 @@ class ExternalRoute implements RouteEntry, ResolvedRoute { } } +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); + spec.paths = {}; + for (const url in localPaths) { + spec.paths[`${basePath}${url}`] = localPaths[url]; + } + + return spec; +} + const onFinishedAsync = promisify(onFinished); /** diff --git a/packages/rest/src/router/index.ts b/packages/rest/src/router/index.ts index 61dffd500fbe..395024076bdc 100644 --- a/packages/rest/src/router/index.ts +++ b/packages/rest/src/router/index.ts @@ -21,3 +21,4 @@ export * from './routing-table'; export * from './route-sort'; export * from './openapi-path'; export * from './trie'; +export * from './router-spec'; diff --git a/packages/rest/src/router/router-spec.ts b/packages/rest/src/router/router-spec.ts new file mode 100644 index 000000000000..31b8aa2e0a40 --- /dev/null +++ b/packages/rest/src/router/router-spec.ts @@ -0,0 +1,34 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {OpenApiSpec} from '@loopback/openapi-v3-types'; + +export type RouterSpec = Pick; + +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 > 0) { + 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); + } + } +} diff --git a/packages/rest/src/router/routing-table.ts b/packages/rest/src/router/routing-table.ts index 54a1e45904c7..234c525293a6 100644 --- a/packages/rest/src/router/routing-table.ts +++ b/packages/rest/src/router/routing-table.ts @@ -19,10 +19,10 @@ import { ControllerFactory, ControllerRoute, } from './controller-route'; +import {ExternalExpressRoutes} from './external-express-routes'; import {validateApiPath} from './openapi-path'; import {RestRouter} from './rest-router'; import {ResolvedRoute, RouteEntry} from './route-entry'; -import {ExternalExpressRoutes} from './external-express-routes'; import {TrieRouter} from './trie-router'; const debug = debugFactory('loopback:rest:routing-table'); @@ -137,7 +137,7 @@ export class RoutingTable { if (this._externalRoutes) { debug( - 'No API route found for %s %s, trying to find a static asset', + 'No API route found for %s %s, trying to find an external Express route', request.method, request.path, );