Skip to content

Commit

Permalink
feat: Support redirection through errors
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Dec 9, 2021
1 parent 520e4fe commit 7163a03
Show file tree
Hide file tree
Showing 12 changed files with 192 additions and 21 deletions.
17 changes: 13 additions & 4 deletions config/ldp/handler/components/error-handler.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
]
}
}
]
Expand Down
23 changes: 23 additions & 0 deletions src/http/output/error/RedirectingErrorHandler.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
9 changes: 5 additions & 4 deletions src/http/output/response/RedirectResponseDescription.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 2 additions & 1 deletion src/identity/IdentityProviderHttpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }};
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down
3 changes: 2 additions & 1 deletion src/server/util/RedirectAllHttpHandler.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 changes: 15 additions & 0 deletions src/util/errors/FoundHttpError.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions src/util/errors/MovedPermanentlyHttpError.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions src/util/errors/RedirectHttpError.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions test/unit/http/output/error/RedirectingErrorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 6 additions & 11 deletions test/unit/http/output/response/RedirectResponseDescription.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 63 additions & 0 deletions test/unit/util/errors/RedirectHttpError.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit 7163a03

Please sign in to comment.