feat: Support redirection through errors

This commit is contained in:
Joachim Van Herwegen
2021-11-12 10:10:22 +01:00
parent 520e4fe42f
commit 7163a0317b
12 changed files with 192 additions and 21 deletions

View File

@@ -7,10 +7,19 @@
"@type": "SafeErrorHandler",
"showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" },
"errorHandler": {
"comment": "Changes an error into a valid representation to send as a response.",
"@type": "ConvertingErrorHandler",
"converter": { "@id": "urn:solid-server:default:UiEnabledConverter" },
"showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
"@type": "WaterfallHandler",
"handlers": [
{
"comment": "Internally redirects are created by throwing a specific error, this handler converts them to the correct response.",
"@type": "RedirectingErrorHandler"
},
{
"comment": "Converts an Error object into a representation for an HTTP response.",
"@type": "ConvertingErrorHandler",
"converter": { "@id": "urn:solid-server:default:UiEnabledConverter" },
"showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
}
]
}
}
]

View File

@@ -0,0 +1,23 @@
import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError';
import { RedirectHttpError } from '../../../util/errors/RedirectHttpError';
import { RedirectResponseDescription } from '../response/RedirectResponseDescription';
import type { ResponseDescription } from '../response/ResponseDescription';
import type { ErrorHandlerArgs } from './ErrorHandler';
import { ErrorHandler } from './ErrorHandler';
/**
* Internally we create redirects by throwing specific {@link RedirectHttpError}s.
* This Error handler converts those to {@link RedirectResponseDescription}s that are used for output.
*/
export class RedirectingErrorHandler extends ErrorHandler {
public async canHandle({ error }: ErrorHandlerArgs): Promise<void> {
if (!RedirectHttpError.isInstance(error)) {
throw new NotImplementedHttpError('Only redirect errors are supported.');
}
}
public async handle({ error }: ErrorHandlerArgs): Promise<ResponseDescription> {
// Cast verified by canHandle
return new RedirectResponseDescription(error as RedirectHttpError);
}
}

View File

@@ -1,14 +1,15 @@
import { DataFactory } from 'n3';
import type { RedirectHttpError } from '../../../util/errors/RedirectHttpError';
import { SOLID_HTTP } from '../../../util/Vocabularies';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { ResponseDescription } from './ResponseDescription';
/**
* Corresponds to a 301/302 response, containing the relevant location metadata.
* Corresponds to a redirect response, containing the relevant location metadata.
*/
export class RedirectResponseDescription extends ResponseDescription {
public constructor(location: string, permanently = false) {
const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(location) });
super(permanently ? 301 : 302, metadata);
public constructor(error: RedirectHttpError) {
const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(error.location) });
super(error.statusCode, metadata);
}
}

View File

@@ -10,6 +10,7 @@ import { OperationHttpHandler } from '../server/OperationHttpHandler';
import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter';
import { APPLICATION_JSON } from '../util/ContentTypes';
import { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
import { FoundHttpError } from '../util/errors/FoundHttpError';
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
import { joinUrl, trimTrailingSlashes } from '../util/PathUtil';
import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil';
@@ -167,7 +168,7 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler {
}
// Create a redirect URL with the OIDC library
const location = await this.interactionCompleter.handleSafe({ ...result.details, request });
responseDescription = new RedirectResponseDescription(location);
responseDescription = new RedirectResponseDescription(new FoundHttpError(location));
} else if (result.type === 'error') {
// We want to show the errors on the original page in case of html interactions, so we can't just throw them here
const preferences = { type: { [APPLICATION_JSON]: 1 }};

View File

@@ -86,6 +86,7 @@ export * from './http/ldp/PutOperationHandler';
// HTTP/Output/Error
export * from './http/output/error/ConvertingErrorHandler';
export * from './http/output/error/ErrorHandler';
export * from './http/output/error/RedirectingErrorHandler';
export * from './http/output/error/SafeErrorHandler';
// HTTP/Output/Metadata
@@ -320,13 +321,16 @@ export * from './util/errors/BadRequestHttpError';
export * from './util/errors/ConflictHttpError';
export * from './util/errors/ErrorUtil';
export * from './util/errors/ForbiddenHttpError';
export * from './util/errors/FoundHttpError';
export * from './util/errors/HttpError';
export * from './util/errors/HttpErrorUtil';
export * from './util/errors/InternalServerError';
export * from './util/errors/MethodNotAllowedHttpError';
export * from './util/errors/MovedPermanentlyHttpError';
export * from './util/errors/NotFoundHttpError';
export * from './util/errors/NotImplementedHttpError';
export * from './util/errors/PreconditionFailedHttpError';
export * from './util/errors/RedirectHttpError';
export * from './util/errors/SystemError';
export * from './util/errors/UnauthorizedHttpError';
export * from './util/errors/UnsupportedMediaTypeHttpError';

View File

@@ -1,6 +1,7 @@
import type { TargetExtractor } from '../../http/input/identifier/TargetExtractor';
import { RedirectResponseDescription } from '../../http/output/response/RedirectResponseDescription';
import type { ResponseWriter } from '../../http/output/ResponseWriter';
import { FoundHttpError } from '../../util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { getRelativeUrl, joinUrl } from '../../util/PathUtil';
import type { HttpHandlerInput } from '../HttpHandler';
@@ -40,7 +41,7 @@ export class RedirectAllHttpHandler extends HttpHandler {
}
public async handle({ response }: HttpHandlerInput): Promise<void> {
const result = new RedirectResponseDescription(joinUrl(this.baseUrl, this.target));
const result = new RedirectResponseDescription(new FoundHttpError(joinUrl(this.baseUrl, this.target)));
await this.responseWriter.handleSafe({ response, result });
}
}

View File

@@ -0,0 +1,15 @@
import type { HttpErrorOptions } from './HttpError';
import { RedirectHttpError } from './RedirectHttpError';
/**
* Error used for resources that have been moved temporarily.
*/
export class FoundHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(302, location, 'FoundHttpError', message, options);
}
public static isInstance(error: any): error is FoundHttpError {
return RedirectHttpError.isInstance(error) && error.statusCode === 302;
}
}

View File

@@ -0,0 +1,15 @@
import type { HttpErrorOptions } from './HttpError';
import { RedirectHttpError } from './RedirectHttpError';
/**
* Error used for resources that have been moved permanently.
*/
export class MovedPermanentlyHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(301, location, 'MovedPermanentlyHttpError', message, options);
}
public static isInstance(error: any): error is MovedPermanentlyHttpError {
return RedirectHttpError.isInstance(error) && error.statusCode === 301;
}
}

View File

@@ -0,0 +1,19 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
/**
* Abstract class representing a 3xx redirect.
*/
export abstract class RedirectHttpError extends HttpError {
public readonly location: string;
protected constructor(statusCode: number, location: string, name: string, message?: string,
options?: HttpErrorOptions) {
super(statusCode, name, message, options);
this.location = location;
}
public static isInstance(error: any): error is RedirectHttpError {
return HttpError.isInstance(error) && typeof (error as any).location === 'string';
}
}

View File

@@ -0,0 +1,25 @@
import { RedirectingErrorHandler } from '../../../../../src/http/output/error/RedirectingErrorHandler';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError';
import { SOLID_HTTP } from '../../../../../src/util/Vocabularies';
describe('A RedirectingErrorHandler', (): void => {
const preferences = {};
const handler = new RedirectingErrorHandler();
it('only accepts redirect errors.', async(): Promise<void> => {
const unsupportedError = new BadRequestHttpError();
await expect(handler.canHandle({ error: unsupportedError, preferences })).rejects.toThrow(NotImplementedHttpError);
const supportedError = new FoundHttpError('http://test.com/foo/bar');
await expect(handler.canHandle({ error: supportedError, preferences })).resolves.toBeUndefined();
});
it('creates redirect responses.', async(): Promise<void> => {
const error = new FoundHttpError('http://test.com/foo/bar');
const result = await handler.handle({ error, preferences });
expect(result.statusCode).toBe(error.statusCode);
expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(error.location);
});
});

View File

@@ -1,18 +1,13 @@
import { RedirectResponseDescription } from '../../../../../src/http/output/response/RedirectResponseDescription';
import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError';
import { SOLID_HTTP } from '../../../../../src/util/Vocabularies';
describe('A RedirectResponseDescription', (): void => {
const location = 'http://test.com/foo';
const error = new FoundHttpError('http://test.com/foo');
it('has status code 302 and a location.', async(): Promise<void> => {
const description = new RedirectResponseDescription(location);
expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location);
expect(description.statusCode).toBe(302);
});
it('has status code 301 if the change is permanent.', async(): Promise<void> => {
const description = new RedirectResponseDescription(location, true);
expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location);
expect(description.statusCode).toBe(301);
it('has status the code and location of the error.', async(): Promise<void> => {
const description = new RedirectResponseDescription(error);
expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(error.location);
expect(description.statusCode).toBe(error.statusCode);
});
});

View File

@@ -0,0 +1,63 @@
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import type { HttpErrorOptions } from '../../../../src/util/errors/HttpError';
import { MovedPermanentlyHttpError } from '../../../../src/util/errors/MovedPermanentlyHttpError';
import { RedirectHttpError } from '../../../../src/util/errors/RedirectHttpError';
class FixedRedirectHttpError extends RedirectHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(0, location, '', message, options);
}
}
describe('RedirectHttpError', (): void => {
const errors: [string, number, typeof FixedRedirectHttpError][] = [
[ 'MovedPermanentlyHttpError', 301, MovedPermanentlyHttpError ],
[ 'FoundHttpError', 302, FoundHttpError ],
];
describe.each(errors)('%s', (name, statusCode, constructor): void => {
const location = 'http://test.com/foo/bar';
const options = {
cause: new Error('cause'),
errorCode: 'E1234',
details: {},
};
const instance = new constructor(location, 'my message', options);
it(`is an instance of ${name}.`, (): void => {
expect(constructor.isInstance(instance)).toBeTruthy();
});
it(`has name ${name}.`, (): void => {
expect(instance.name).toBe(name);
});
it(`has status code ${statusCode}.`, (): void => {
expect(instance.statusCode).toBe(statusCode);
});
it('sets the location.', (): void => {
expect(instance.location).toBe(location);
});
it('sets the message.', (): void => {
expect(instance.message).toBe('my message');
});
it('sets the cause.', (): void => {
expect(instance.cause).toBe(options.cause);
});
it('sets the error code.', (): void => {
expect(instance.errorCode).toBe(options.errorCode);
});
it('defaults to an HTTP-specific error code.', (): void => {
expect(new constructor(location).errorCode).toBe(`H${statusCode}`);
});
it('sets the details.', (): void => {
expect(instance.details).toBe(options.details);
});
});
});