mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Support redirection through errors
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
23
src/http/output/error/RedirectingErrorHandler.ts
Normal file
23
src/http/output/error/RedirectingErrorHandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
15
src/util/errors/FoundHttpError.ts
Normal file
15
src/util/errors/FoundHttpError.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/util/errors/MovedPermanentlyHttpError.ts
Normal file
15
src/util/errors/MovedPermanentlyHttpError.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
src/util/errors/RedirectHttpError.ts
Normal file
19
src/util/errors/RedirectHttpError.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
25
test/unit/http/output/error/RedirectingErrorHandler.test.ts
Normal file
25
test/unit/http/output/error/RedirectingErrorHandler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
63
test/unit/util/errors/RedirectHttpError.test.ts
Normal file
63
test/unit/util/errors/RedirectHttpError.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user