diff --git a/packages/rest/src/__tests__/acceptance/routing/routing.acceptance.ts b/packages/rest/src/__tests__/acceptance/routing/routing.acceptance.ts index 407bad662f12..871eae9e80a3 100644 --- a/packages/rest/src/__tests__/acceptance/routing/routing.acceptance.ts +++ b/packages/rest/src/__tests__/acceptance/routing/routing.acceptance.ts @@ -735,6 +735,53 @@ describe('Routing', () => { .get('/greet/world') .expect(200, 'HELLO WORLD'); }); + + it('gives precedence to redirect routes over controller methods', async () => { + class MyController { + @get('/hello', { + responses: {}, + }) + hello(): string { + return 'hello'; + } + @get('/hello/world') + helloWorld() { + return `hello world`; + } + } + const app = givenAnApplication(); + const server = await givenAServer(app); + server.basePath('/api'); + server.redirect('/test/hello', '/hello/world'); + givenControllerInApp(app, MyController); + const response = await whenIMakeRequestTo(server) + .get('/api/test/hello') + .expect(303); + // new request to verify the redirect target + await whenIMakeRequestTo(server) + .get(response.header.location) + .expect(200, 'hello world'); + }); + + it('gives precedence to redirect routes over route methods', async () => { + const app = new RestApplication(); + app.route( + 'get', + '/greet/{name}', + anOperationSpec() + .withParameter({name: 'name', in: 'path', type: 'string'}) + .build(), + (name: string) => `hello ${name}`, + ); + app.redirect('/hello/john', '/greet/john'); + const response = await whenIMakeRequestTo(app) + .get('/hello/john') + .expect(303); + // new request to verify the redirect target + await whenIMakeRequestTo(app) + .get(response.header.location) + .expect(200, 'hello john'); + }); }); /* ===== HELPERS ===== */ diff --git a/packages/rest/src/__tests__/integration/rest.application.integration.ts b/packages/rest/src/__tests__/integration/rest.application.integration.ts index 1521dae2ba5a..1679248693e7 100644 --- a/packages/rest/src/__tests__/integration/rest.application.integration.ts +++ b/packages/rest/src/__tests__/integration/rest.application.integration.ts @@ -7,7 +7,7 @@ import {anOperationSpec} from '@loopback/openapi-spec-builder'; import {Client, createRestAppClient, expect} from '@loopback/testlab'; import * as fs from 'fs'; import * as path from 'path'; -import {RestApplication, RestServer, RestServerConfig} from '../..'; +import {RestApplication, RestServer, RestServerConfig, get} from '../..'; const ASSETS = path.resolve(__dirname, '../../../fixtures/assets'); @@ -145,8 +145,28 @@ describe('RestApplication (integration)', () => { }); }); + it('can app redirect routes with custom status', async () => { + givenApplication(); + restApp.controller(DummyController); + restApp.redirect('/fake/html', '/html', 304); + await restApp.start(); + client = createRestAppClient(restApp); + const response = await client.get('/fake/html').expect(304); + await client.get(response.header.location).expect(200, 'Hi'); + }); + function givenApplication(options?: {rest: RestServerConfig}) { options = options || {rest: {port: 0, host: '127.0.0.1'}}; restApp = new RestApplication(options); } }); + +class DummyController { + constructor() {} + @get('/html', { + responses: {}, + }) + ping(): string { + return 'Hi'; + } +} diff --git a/packages/rest/src/__tests__/integration/rest.server.integration.ts b/packages/rest/src/__tests__/integration/rest.server.integration.ts index ab1e96002276..46f43e562600 100644 --- a/packages/rest/src/__tests__/integration/rest.server.integration.ts +++ b/packages/rest/src/__tests__/integration/rest.server.integration.ts @@ -735,6 +735,39 @@ paths: ); expect(response.body.servers).to.containEql({url: '/api'}); }); + + it('can redirect routes when it does not exist', async () => { + server.controller(DummyController); + server.redirect('/page/html', '/html'); + const response = await createClientForHandler(server.requestHandler) + .get('/api/page/html') + .expect(303); + await createClientForHandler(server.requestHandler) + .get(response.header.location) + .expect(200, 'Hi'); + }); + + it('can redirect routes', async () => { + server.controller(DummyController); + server.redirect('/hello', '/endpoint'); + const response = await createClientForHandler(server.requestHandler) + .get('/api/hello') + .expect(303); + await createClientForHandler(server.requestHandler) + .get(response.header.location) + .expect(200, 'hello'); + }); + + it('can server redirect routes with custom status', async () => { + server.controller(DummyController); + server.redirect('/hello', '/endpoint', 304); + const response = await createClientForHandler(server.requestHandler) + .get('/api/hello') + .expect(304); + await createClientForHandler(server.requestHandler) + .get(response.header.location) + .expect(200, 'hello'); + }); }); async function givenAServer( @@ -763,6 +796,12 @@ paths: ping(): string { return 'Hi'; } + @get('/endpoint', { + responses: {}, + }) + hello(): string { + return 'hello'; + } } class DummyXmlController { diff --git a/packages/rest/src/__tests__/unit/rest.server/rest.server.redirect.unit.ts b/packages/rest/src/__tests__/unit/rest.server/rest.server.redirect.unit.ts new file mode 100644 index 000000000000..5d4b826c3076 --- /dev/null +++ b/packages/rest/src/__tests__/unit/rest.server/rest.server.redirect.unit.ts @@ -0,0 +1,19 @@ +// Copyright IBM Corp. 2017,2018. 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 {Application} from '@loopback/core'; +import {RestServer, RestComponent} from '../../..'; + +describe('RestServer.redirect()', () => { + it('binds the redirect route', async () => { + const app = new Application(); + app.component(RestComponent); + const server = await app.getServer(RestServer); + server.redirect('/test', '/test/'); + let boundRoutes = server.find('routes.*').map(b => b.key); + expect(boundRoutes).to.containEql('routes.get %2Ftest'); + }); +}); diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index d256158c3550..555866e342bf 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -228,6 +228,20 @@ export class RestApplication extends Application implements HttpServerLike { } } + /** + * Redirect route by invoking a handler function. + * + * ```ts + * app.redirect('/explorer', '/explorer/'); + * ``` + * + * @param source URL path of the redirect endpoint + * @param target URL path of the endpoint + */ + redirect(source: string, target: string, statusCode?: number): Binding { + return this.restServer.redirect(source, target, statusCode); + } + /** * Set the OpenAPI specification that defines the REST API schema for this * application. All routes, parameter definitions and return types will be diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 564cdf9d6ce4..9fde8f34eced 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -42,6 +42,7 @@ import { RouteEntry, RoutingTable, StaticAssetsRoute, + RedirectRoute, } from './router'; import {DefaultSequence, SequenceFunction, SequenceHandler} from './sequence'; import { @@ -629,6 +630,22 @@ export class RestServer extends Context implements Server, HttpServerLike { ); } + /** + * Redirect route by invoking a handler function. + * + * ```ts + * server.redirect('/explorer', '/explorer/'); + * ``` + * + * @param source URL path of the redirect endpoint + * @param target URL path of the endpoint + */ + redirect(source: string, target: string, statusCode?: number): Binding { + return this.route( + new RedirectRoute(source, this._basePath + target, statusCode), + ); + } + // The route for static assets private _staticAssetRoute = new StaticAssetsRoute(); diff --git a/packages/rest/src/router/index.ts b/packages/rest/src/router/index.ts index 86ded6b6eb6e..559c8969b59f 100644 --- a/packages/rest/src/router/index.ts +++ b/packages/rest/src/router/index.ts @@ -9,6 +9,7 @@ export * from './base-route'; export * from './controller-route'; export * from './handler-route'; export * from './static-assets-route'; +export * from './redirect-route'; // routers export * from './rest-router'; diff --git a/packages/rest/src/router/redirect-route.ts b/packages/rest/src/router/redirect-route.ts new file mode 100644 index 000000000000..333e9746c267 --- /dev/null +++ b/packages/rest/src/router/redirect-route.ts @@ -0,0 +1,42 @@ +import {RouteEntry, ResolvedRoute} from '.'; +import {RequestContext} from '../request-context'; +import {OperationObject, SchemasObject} from '@loopback/openapi-v3-types'; +import {OperationArgs, OperationRetval, PathParameterValues} from '../types'; + +export class RedirectRoute implements RouteEntry, ResolvedRoute { + // ResolvedRoute API + readonly pathParams: PathParameterValues = []; + readonly schemas: SchemasObject = {}; + + // RouteEntry implementation + readonly verb: string = 'get'; + readonly path: string = this.sourcePath; + readonly spec: OperationObject = { + description: 'LoopBack Redirect route', + 'x-visibility': 'undocumented', + responses: {}, + }; + + constructor( + public readonly sourcePath: string, + public targetPath: string, + public statusCode: number = 303, + ) { + this.sourcePath = sourcePath; + } + + async invokeHandler( + {response}: RequestContext, + args: OperationArgs, + ): Promise { + response.redirect(this.statusCode, this.targetPath); + } + + updateBindings(requestContext: RequestContext) { + // no-op + } + + describe(): string { + return `RedirectRoute from "${this.sourcePath}" to "${this.targetPath}"`; + } +}