From 6eb925a91960048db5b151f4a651db4743ca3337 Mon Sep 17 00:00:00 2001 From: sanad haj Date: Wed, 30 Jan 2019 22:37:09 +0200 Subject: [PATCH] feat(rest): add support for redirect routes Implement a new class RedirectRoute to provide redirection from a given URL path to a new URL path. Add RestServer and RestApplication methods for defining redirect routes: redirect(source: string, target: string, statusCode?: number); --- docs/site/Routes.md | 25 ++++++++++ .../acceptance/routing/routing.acceptance.ts | 47 +++++++++++++++++++ .../rest.application.integration.ts | 20 +++++++- .../integration/rest.server.integration.ts | 41 ++++++++++++++++ .../rest.server/rest.server.redirect.unit.ts | 19 ++++++++ packages/rest/src/rest.application.ts | 22 +++++++++ packages/rest/src/rest.server.ts | 25 ++++++++++ packages/rest/src/router/index.ts | 1 + packages/rest/src/router/redirect-route.ts | 42 +++++++++++++++++ 9 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packages/rest/src/__tests__/unit/rest.server/rest.server.redirect.unit.ts create mode 100644 packages/rest/src/router/redirect-route.ts diff --git a/docs/site/Routes.md b/docs/site/Routes.md index 0185ece27fc6..0bb64bec2b24 100644 --- a/docs/site/Routes.md +++ b/docs/site/Routes.md @@ -201,3 +201,28 @@ class MySequence extends DefaultSequence { } } ``` + +## Implementing HTTP redirects + +Both `RestServer` and `RestApplication` classes provide API for registering +routes that will redirect clients to a given URL. + +Example use: + +{% include code-caption.html content="src/application.ts" %} + +```ts +import {RestApplication} from '@loopback/rest'; + +export class MyApplication extends RestApplication { + constructor(options: ApplicationConfig = {}) { + super(options); + + // Use the default status code 303 See Other + this.redirect('/', '/home'); + + // Specify a custom status code 301 Moved Permanently + this.redirect('/stats', '/status', 301); + } +} +``` 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..e9988e1ff7c5 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,6 +145,24 @@ describe('RestApplication (integration)', () => { }); }); + it('creates a redirect route with a custom status code', async () => { + givenApplication(); + + class PingController { + @get('/ping') + ping(): string { + return 'Hi'; + } + } + restApp.controller(PingController); + + restApp.redirect('/custom/ping', '/ping', 304); + await restApp.start(); + client = createRestAppClient(restApp); + const response = await client.get('/custom/ping').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); diff --git a/packages/rest/src/__tests__/integration/rest.server.integration.ts b/packages/rest/src/__tests__/integration/rest.server.integration.ts index 8185af600ca7..59d370fcaa8a 100644 --- a/packages/rest/src/__tests__/integration/rest.server.integration.ts +++ b/packages/rest/src/__tests__/integration/rest.server.integration.ts @@ -703,6 +703,30 @@ paths: await server.stop(); }); + it('creates a redirect route with the default status code', async () => { + const server = await givenAServer(); + server.controller(DummyController); + server.redirect('/page/html', '/html'); + const response = await createClientForHandler(server.requestHandler) + .get('/page/html') + .expect(303); + await createClientForHandler(server.requestHandler) + .get(response.header.location) + .expect(200, 'Hi'); + }); + + it('creates a redirect route with a custom status code', async () => { + const server = await givenAServer(); + server.controller(DummyController); + server.redirect('/page/html', '/html', 304); + const response = await createClientForHandler(server.requestHandler) + .get('/page/html') + .expect(304); + await createClientForHandler(server.requestHandler) + .get(response.header.location) + .expect(200, 'Hi'); + }); + describe('basePath', () => { const root = ASSETS; let server: RestServer; @@ -751,6 +775,17 @@ paths: ); expect(response.body.servers).to.containEql({url: '/api'}); }); + + it('controls redirect locations', 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'); + }); }); async function givenAServer( @@ -779,6 +814,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..9ecfb576f48a 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -228,6 +228,28 @@ export class RestApplication extends Application implements HttpServerLike { } } + /** + * Register a route redirecting callers to a different URL. + * + * ```ts + * app.redirect('/explorer', '/explorer/'); + * ``` + * + * @param fromPath URL path of the redirect endpoint + * @param toPathOrUrl Location (URL path or full URL) where to redirect to. + * If your server is configured with a custom `basePath`, then the base path + * is prepended to the target location. + * @param statusCode HTTP status code to respond with, + * defaults to 303 (See Other). + */ + redirect( + fromPath: string, + toPathOrUrl: string, + statusCode?: number, + ): Binding { + return this.restServer.redirect(fromPath, toPathOrUrl, 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 f9f6e939c54b..206d880b570c 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 { @@ -666,6 +667,30 @@ export class RestServer extends Context implements Server, HttpServerLike { ); } + /** + * Register a route redirecting callers to a different URL. + * + * ```ts + * server.redirect('/explorer', '/explorer/'); + * ``` + * + * @param fromPath URL path of the redirect endpoint + * @param toPathOrUrl Location (URL path or full URL) where to redirect to. + * If your server is configured with a custom `basePath`, then the base path + * is prepended to the target location. + * @param statusCode HTTP status code to respond with, + * defaults to 303 (See Other). + */ + redirect( + fromPath: string, + toPathOrUrl: string, + statusCode?: number, + ): Binding { + return this.route( + new RedirectRoute(fromPath, this._basePath + toPathOrUrl, 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..71da6fdd64d4 --- /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( + private readonly sourcePath: string, + private readonly targetLocation: string, + private readonly statusCode: number = 303, + ) {} + + async invokeHandler( + {response}: RequestContext, + args: OperationArgs, + ): Promise { + response.redirect(this.statusCode, this.targetLocation); + } + + updateBindings(requestContext: RequestContext) { + // no-op + } + + describe(): string { + return `RedirectRoute from "${this.sourcePath}" to "${ + this.targetLocation + }"`; + } +}