feat: Add RedirectingHttpHandler

* feat: redirect handler

Signed-off-by: Wouter Termont <woutermont@gmail.com>

* chore: rewrite reduction as loop

Signed-off-by: Wouter Termont <woutermont@gmail.com>

* chore: remove example import

Signed-off-by: Wouter Termont <woutermont@gmail.com>

* feat: add more redirect flavours

Signed-off-by: Wouter Termont <woutermont@gmail.com>

* chore: RedirectingHttpHandler with RedirectAllHttpHandler

Signed-off-by: Wouter Termont <woutermont@gmail.com>

* chore: replace RedirectAllHttpHandler with RedirectingHttpHandler

* chore: revert 5956385 (chore: replace RedirectAllHttpHandler with RedirectingHttpHandler)

This reverts commit 5956385c4180e8e8914b9199c4ed6ca8ae7d39fb.

* docs: complete constructor params

Signed-off-by: Wouter Termont <woutermont@gmail.com>
This commit is contained in:
Wouter Termont 2022-04-11 09:49:36 +02:00 committed by GitHub
parent 11bc657e68
commit 468e11d906
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 284 additions and 0 deletions

View File

@ -0,0 +1,20 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Example handler to configure redirect patterns.",
"@id": "urn:solid-server:default:RedirectHandler",
"@type": "RedirectingHttpHandler",
"redirects": [
{
"RedirectingHttpHandler:_redirects_key": "/from/(.*)",
"RedirectingHttpHandler:_redirects_value": "/to/$1"
}
],
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
"statusCode": "303"
}
]
}

View File

@ -258,6 +258,7 @@ export * from './server/HttpResponse';
export * from './server/HttpServerFactory';
export * from './server/OperationHttpHandler';
export * from './server/ParsingHttpHandler';
export * from './server/RedirectingHttpHandler';
export * from './server/WebSocketHandler';
export * from './server/WebSocketServerFactory';

View File

@ -0,0 +1,109 @@
import type { TargetExtractor } from '../http/input/identifier/TargetExtractor';
import { RedirectResponseDescription } from '../http/output/response/RedirectResponseDescription';
import type { ResponseWriter } from '../http/output/ResponseWriter';
import { getLoggerFor } from '../logging/LogUtil';
import { FoundHttpError } from '../util/errors/FoundHttpError';
import { MovedPermanentlyHttpError } from '../util/errors/MovedPermanentlyHttpError';
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
import { PermanentRedirectHttpError } from '../util/errors/PermanentRedirectHttpError';
import type { RedirectHttpError } from '../util/errors/RedirectHttpError';
import { SeeOtherHttpError } from '../util/errors/SeeOtherHttpError';
import { TemporaryRedirectHttpError } from '../util/errors/TemporaryRedirectHttpError';
import { getRelativeUrl, joinUrl } from '../util/PathUtil';
import type { HttpHandlerInput } from './HttpHandler';
import { HttpHandler } from './HttpHandler';
import type { HttpRequest } from './HttpRequest';
const redirectErrorFactories: Record<301 | 302 | 303 | 307 | 308, (location: string) => RedirectHttpError> = {
301: (location: string): RedirectHttpError => new MovedPermanentlyHttpError(location),
302: (location: string): RedirectHttpError => new FoundHttpError(location),
303: (location: string): RedirectHttpError => new SeeOtherHttpError(location),
307: (location: string): RedirectHttpError => new TemporaryRedirectHttpError(location),
308: (location: string): RedirectHttpError => new PermanentRedirectHttpError(location),
};
/**
* Handler that redirects paths matching given patterns
* to their corresponding URL, substituting selected groups.
*/
export class RedirectingHttpHandler extends HttpHandler {
private readonly logger = getLoggerFor(this);
private readonly redirects: {
regex: RegExp;
redirectPattern: string;
}[];
/**
* Creates a handler for the provided redirects.
* @param redirects - A mapping between URL patterns.
* @param targetExtractor - To extract the target from the request.
* @param responseWriter - To write the redirect to the response.
* @param statusCode - Desired 30x redirection code (defaults to 308).
*/
public constructor(
redirects: Record<string, string>,
private readonly baseUrl: string,
private readonly targetExtractor: TargetExtractor,
private readonly responseWriter: ResponseWriter,
private readonly statusCode: 301 | 302 | 303 | 307 | 308 = 308,
) {
super();
// Create an array of (regexp, redirect) pairs
this.redirects = Object.keys(redirects).map(
(pattern): { regex: RegExp; redirectPattern: string } => ({
regex: new RegExp(pattern, 'u'),
redirectPattern: redirects[pattern],
}),
);
}
public async canHandle({ request }: HttpHandlerInput): Promise<void> {
// Try to find redirect for target URL
await this.findRedirect(request);
}
public async handle({ request, response }: HttpHandlerInput): Promise<void> {
// Try to find redirect for target URL
const redirect = await this.findRedirect(request);
// Send redirect response
this.logger.info(`Redirecting ${request.url} to ${redirect}`);
const result = new RedirectResponseDescription(redirectErrorFactories[this.statusCode](redirect));
await this.responseWriter.handleSafe({ response, result });
}
private async findRedirect(request: HttpRequest): Promise<string> {
// Retrieve target relative to base URL
const target = await getRelativeUrl(this.baseUrl, request, this.targetExtractor);
// Get groups and redirect of first matching pattern
let result;
for (const { regex, redirectPattern } of this.redirects) {
const match = regex.exec(target);
if (match) {
result = { match, redirectPattern };
break;
}
}
// Only return if a redirect is configured for the requested URL
if (!result) {
throw new NotImplementedHttpError(`No redirect configured for ${target}`);
}
// Build redirect URL from regexp result
const { match, redirectPattern } = result;
const redirect = match.reduce(
(prev, param, index): string => prev.replace(`$${index}`, param),
redirectPattern,
);
// Don't redirect if target is already correct
if (redirect === target) {
throw new NotImplementedHttpError('Target is already correct.');
}
return /^(?:[a-z]+:)?\/\//ui.test(redirect) ? redirect : joinUrl(this.baseUrl, redirect);
}
}

View File

@ -3,6 +3,7 @@ import { RedirectHttpError } from './RedirectHttpError';
/**
* Error used for resources that have been moved temporarily.
* Methods other than GET may or may not be changed to GET in subsequent requests.
*/
export class FoundHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {

View File

@ -3,6 +3,7 @@ import { RedirectHttpError } from './RedirectHttpError';
/**
* Error used for resources that have been moved permanently.
* Methods other than GET may or may not be changed to GET in subsequent requests.
*/
export class MovedPermanentlyHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {

View File

@ -0,0 +1,16 @@
import type { HttpErrorOptions } from './HttpError';
import { RedirectHttpError } from './RedirectHttpError';
/**
* Error used for resources that have been moved permanently.
* Method and body should not be changed in subsequent requests.
*/
export class PermanentRedirectHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(308, location, 'PermanentRedirectHttpError', message, options);
}
public static isInstance(error: any): error is PermanentRedirectHttpError {
return RedirectHttpError.isInstance(error) && error.statusCode === 308;
}
}

View File

@ -0,0 +1,17 @@
import type { HttpErrorOptions } from './HttpError';
import { RedirectHttpError } from './RedirectHttpError';
/**
* Error used to redirect not to the requested resource itself, but to another page,
* for example a representation of a real-world object.
* The method used to display this redirected page is always GET.
*/
export class SeeOtherHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(303, location, 'SeeOtherHttpError', message, options);
}
public static isInstance(error: any): error is SeeOtherHttpError {
return RedirectHttpError.isInstance(error) && error.statusCode === 303;
}
}

View File

@ -0,0 +1,16 @@
import type { HttpErrorOptions } from './HttpError';
import { RedirectHttpError } from './RedirectHttpError';
/**
* Error used for resources that have been moved temporarily.
* Method and body should not be changed in subsequent requests.
*/
export class TemporaryRedirectHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(307, location, 'TemporaryRedirectHttpError', message, options);
}
public static isInstance(error: any): error is TemporaryRedirectHttpError {
return RedirectHttpError.isInstance(error) && error.statusCode === 307;
}
}

View File

@ -0,0 +1,97 @@
import type { TargetExtractor } from '../../../src/http/input/identifier/TargetExtractor';
import type { ResponseWriter } from '../../../src/http/output/ResponseWriter';
import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse';
import { RedirectingHttpHandler } from '../../../src/server/RedirectingHttpHandler';
import { joinUrl } from '../../../src/util/PathUtil';
import { SOLID_HTTP } from '../../../src/util/Vocabularies';
describe('A RedirectingHttpHandler', (): void => {
const baseUrl = 'http://test.com/';
const request = { method: 'GET' } as HttpRequest;
const response = {} as HttpResponse;
let targetExtractor: jest.Mocked<TargetExtractor>;
let responseWriter: jest.Mocked<ResponseWriter>;
let handler: RedirectingHttpHandler;
beforeEach(async(): Promise<void> => {
targetExtractor = {
handleSafe: jest.fn(({ request: req }): ResourceIdentifier => ({ path: joinUrl(baseUrl, req.url!) })),
} as any;
responseWriter = { handleSafe: jest.fn() } as any;
handler = new RedirectingHttpHandler({
'/one': '/two',
'/from/(.*)': 'http://to/t$1',
'/f([aeiou]+)/b([aeiou]+)r': '/f$2/b$1r',
'/s(.)me': '/s$1me',
}, baseUrl, targetExtractor, responseWriter);
});
afterEach(jest.clearAllMocks);
it('does not handle requests without URL.', async(): Promise<void> => {
await expect(handler.canHandle({ request, response }))
.rejects.toThrow('Url must be a string. Received undefined');
await expect(handler.handle({ request, response }))
.rejects.toThrow('Url must be a string. Received undefined');
});
it('does not handle requests with unconfigured URLs.', async(): Promise<void> => {
request.url = '/other';
await expect(handler.canHandle({ request, response }))
.rejects.toThrow('No redirect configured for /other');
await expect(handler.handle({ request, response }))
.rejects.toThrow('No redirect configured for /other');
});
it('does not handle requests redirecting to their own target URL.', async(): Promise<void> => {
request.url = '/same';
await expect(handler.canHandle({ request, response }))
.rejects.toThrow('Target is already correct.');
await expect(handler.handle({ request, response }))
.rejects.toThrow('Target is already correct.');
});
it('handles requests to a known URL.', async(): Promise<void> => {
request.url = '/one';
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({
response,
result: expect.objectContaining({ statusCode: 308 }),
});
const { metadata } = responseWriter.handleSafe.mock.calls[0][0].result;
expect(metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(joinUrl(baseUrl, '/two'));
});
it('handles correctly substitutes group patterns.', async(): Promise<void> => {
request.url = '/fa/boor';
await handler.handle({ request, response });
const { metadata } = responseWriter.handleSafe.mock.calls[0][0].result;
expect(metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(joinUrl(baseUrl, '/foo/bar'));
});
it('redirects to an absolute url if provided.', async(): Promise<void> => {
request.url = '/from/here';
await handler.handle({ request, response });
const { metadata } = responseWriter.handleSafe.mock.calls[0][0].result;
expect(metadata?.get(SOLID_HTTP.terms.location)?.value).toBe('http://to/there');
});
it.each([ 301, 302, 303, 307, 308 ])('redirects with the provided status code: %i.', async(code): Promise<void> => {
request.url = '/one';
(handler as any).statusCode = code;
await handler.handle({ request, response });
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({
response,
result: expect.objectContaining({ statusCode: code }),
});
});
});

View File

@ -1,7 +1,10 @@
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import type { HttpErrorOptions } from '../../../../src/util/errors/HttpError';
import { MovedPermanentlyHttpError } from '../../../../src/util/errors/MovedPermanentlyHttpError';
import { PermanentRedirectHttpError } from '../../../../src/util/errors/PermanentRedirectHttpError';
import { RedirectHttpError } from '../../../../src/util/errors/RedirectHttpError';
import { SeeOtherHttpError } from '../../../../src/util/errors/SeeOtherHttpError';
import { TemporaryRedirectHttpError } from '../../../../src/util/errors/TemporaryRedirectHttpError';
class FixedRedirectHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
@ -13,6 +16,9 @@ describe('RedirectHttpError', (): void => {
const errors: [string, number, typeof FixedRedirectHttpError][] = [
[ 'MovedPermanentlyHttpError', 301, MovedPermanentlyHttpError ],
[ 'FoundHttpError', 302, FoundHttpError ],
[ 'SeeOtherHttpError', 303, SeeOtherHttpError ],
[ 'TemporaryRedirectHttpError', 307, TemporaryRedirectHttpError ],
[ 'PermanentRedirectHttpError', 308, PermanentRedirectHttpError ],
];
describe.each(errors)('%s', (name, statusCode, constructor): void => {