feat: Create an OperationRouterHandler

This allows us to route after an Operation has been parsed
This commit is contained in:
Joachim Van Herwegen 2022-09-29 16:46:46 +02:00
parent da99ff30f6
commit 3db1921633
14 changed files with 266 additions and 154 deletions

View File

@ -3,6 +3,7 @@
"Adapter", "Adapter",
"BaseActivityEmitter", "BaseActivityEmitter",
"BaseHttpError", "BaseHttpError",
"BaseRouterHandler",
"BasicConditions", "BasicConditions",
"BasicRepresentation", "BasicRepresentation",
"ChangeMap", "ChangeMap",

View File

@ -15,7 +15,6 @@
"@type": "RouterHandler", "@type": "RouterHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
"args_allowedMethods": [ "*" ],
"args_allowedPathNames": [ "/setup" ], "args_allowedPathNames": [ "/setup" ],
"args_handler": { "@id": "urn:solid-server:default:SetupParsingHandler" } "args_handler": { "@id": "urn:solid-server:default:SetupParsingHandler" }
} }

View File

@ -20,7 +20,6 @@
"@type": "RouterHandler", "@type": "RouterHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
"args_allowedMethods": [ "*" ],
"args_allowedPathNames": [ "/setup" ], "args_allowedPathNames": [ "/setup" ],
"args_handler": { "@id": "urn:solid-server:default:SetupParsingHandler" } "args_handler": { "@id": "urn:solid-server:default:SetupParsingHandler" }
} }

View File

@ -7,7 +7,6 @@
"@type": "RouterHandler", "@type": "RouterHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
"args_allowedMethods": [ "*" ],
"args_allowedPathNames": [ "^/.oidc/.*", "^/\\.well-known/openid-configuration" ], "args_allowedPathNames": [ "^/.oidc/.*", "^/\\.well-known/openid-configuration" ],
"args_handler": { "args_handler": {
"@type": "OidcHttpHandler", "@type": "OidcHttpHandler",

View File

@ -13,7 +13,6 @@
"@type": "RouterHandler", "@type": "RouterHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
"args_allowedMethods": [ "*" ],
"args_allowedPathNames": [ "^/idp/.*" ], "args_allowedPathNames": [ "^/idp/.*" ],
"args_handler": { "@id": "urn:solid-server:default:IdentityProviderParsingHandler" } "args_handler": { "@id": "urn:solid-server:default:IdentityProviderParsingHandler" }
}, },

View File

@ -60,7 +60,6 @@
"@type": "RouterHandler", "@type": "RouterHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
"args_allowedMethods": [ "*" ],
"args_allowedPathNames": [ "^/.*\\.acl$" ], "args_allowedPathNames": [ "^/.*\\.acl$" ],
"args_handler": { "@id": "urn:solid-server:default:LdpHandler" } "args_handler": { "@id": "urn:solid-server:default:LdpHandler" }
} }

View File

@ -15,7 +15,7 @@ export interface OriginalUrlExtractorArgs {
identifierStrategy: IdentifierStrategy; identifierStrategy: IdentifierStrategy;
/** /**
* Specify wether the OriginalUrlExtractor should include the request query string. * Specify whether the OriginalUrlExtractor should include the request query string.
*/ */
includeQueryString?: boolean; includeQueryString?: boolean;
} }

View File

@ -305,6 +305,8 @@ export * from './server/middleware/WebSocketAdvertiser';
export * from './server/notifications/ActivityEmitter'; export * from './server/notifications/ActivityEmitter';
// Server/Util // Server/Util
export * from './server/util/BaseRouterHandler';
export * from './server/util/OperationRouterHandler';
export * from './server/util/RedirectingHttpHandler'; export * from './server/util/RedirectingHttpHandler';
export * from './server/util/RouterHandler'; export * from './server/util/RouterHandler';

View File

@ -0,0 +1,75 @@
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import type { AsyncHandlerInput, AsyncHandlerOutput } from '../../util/handlers/AsyncHandler';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import { trimTrailingSlashes } from '../../util/PathUtil';
export interface BaseRouterHandlerArgs<T extends AsyncHandler<any, any>> {
/**
* The base URL of the server.
* Not required if no value is provided for `allowedPathNames`.
*/
baseUrl?: string;
/**
* The handler to call if all checks pass.
*/
handler: T;
/**
* The allowed method(s). `*` can be used to indicate all methods are allowed.
* Default is `[ '*' ]`.
*/
allowedMethods?: string[];
/**
* Regular expression(s) used to match the target URL.
* The base URl without trailing slash will be stripped of before applying the regular expressions,
* so the input will always start with a `/`.
* Default is `[ '.*' ]`.
*/
allowedPathNames?: string[];
}
/**
* Checks if a given method and path are satisfied and allows its handler to be executed if so.
*
* Implementations of this class should call `canHandleInput` in their `canHandle` call with the correct parameters.
*
* `canHandleInput` expects a ResourceIdentifier to indicate it expects the target to have been validated already.
*/
export abstract class BaseRouterHandler<T extends AsyncHandler<any, any>>
extends AsyncHandler<AsyncHandlerInput<T>, AsyncHandlerOutput<T>> {
protected readonly baseUrlLength: number;
protected readonly handler: T;
protected readonly allowedMethods: string[];
protected readonly allMethods: boolean;
protected readonly allowedPathNamesRegEx: RegExp[];
protected constructor(args: BaseRouterHandlerArgs<T>) {
super();
if (typeof args.allowedPathNames !== 'undefined' && typeof args.baseUrl !== 'string') {
throw new Error('A value for allowedPathNames requires baseUrl to be defined.');
}
// Trimming trailing slash so regexes can start with `/`
this.baseUrlLength = trimTrailingSlashes(args.baseUrl ?? '').length;
this.handler = args.handler;
this.allowedMethods = args.allowedMethods ?? [ '*' ];
this.allMethods = this.allowedMethods.includes('*');
this.allowedPathNamesRegEx = (args.allowedPathNames ?? [ '.*' ]).map((pn): RegExp => new RegExp(pn, 'u'));
}
protected async canHandleInput(input: AsyncHandlerInput<T>, method: string, target: ResourceIdentifier):
Promise<void> {
if (!this.allMethods && !this.allowedMethods.includes(method)) {
throw new MethodNotAllowedHttpError([ method ], `${method} is not allowed.`);
}
const pathName = target.path.slice(this.baseUrlLength);
if (!this.allowedPathNamesRegEx.some((regex): boolean => regex.test(pathName))) {
throw new NotFoundHttpError(`Cannot handle route ${pathName}`);
}
await this.handler.canHandle(input);
}
public async handle(input: AsyncHandlerInput<T>): Promise<AsyncHandlerOutput<T>> {
return this.handler.handle(input);
}
}

View File

@ -0,0 +1,16 @@
import type { OperationHttpHandlerInput, OperationHttpHandler } from '../OperationHttpHandler';
import type { BaseRouterHandlerArgs } from './BaseRouterHandler';
import { BaseRouterHandler } from './BaseRouterHandler';
/**
* A {@link BaseRouterHandler} for an {@link OperationHttpHandler}.
*/
export class OperationRouterHandler extends BaseRouterHandler<OperationHttpHandler> {
public constructor(args: BaseRouterHandlerArgs<OperationHttpHandler>) {
super(args);
}
public async canHandle(input: OperationHttpHandlerInput): Promise<void> {
await super.canHandleInput(input, input.operation.method, input.operation.target);
}
}

View File

@ -1,41 +1,23 @@
import type { TargetExtractor } from '../../http/input/identifier/TargetExtractor'; import type { TargetExtractor } from '../../http/input/identifier/TargetExtractor';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; import type { HttpHandlerInput, HttpHandler } from '../HttpHandler';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import { BaseRouterHandler } from './BaseRouterHandler';
import { ensureTrailingSlash, getRelativeUrl } from '../../util/PathUtil'; import type { BaseRouterHandlerArgs } from './BaseRouterHandler';
import type { HttpHandlerInput } from '../HttpHandler';
import { HttpHandler } from '../HttpHandler';
export interface RouterHandlerArgs { export interface RouterHandlerArgs extends BaseRouterHandlerArgs<HttpHandler> {
baseUrl: string;
targetExtractor: TargetExtractor; targetExtractor: TargetExtractor;
handler: HttpHandler;
allowedMethods: string[];
allowedPathNames: string[];
} }
/** /**
* An HttpHandler that checks if a given method and path are satisfied * A {@link BaseRouterHandler} for an {@link HttpHandler}.
* and allows its handler to be executed if so. * Uses a {@link TargetExtractor} to generate the target identifier.
*
* If `allowedMethods` contains '*' it will match all methods.
*/ */
export class RouterHandler extends HttpHandler { export class RouterHandler extends BaseRouterHandler<HttpHandler> {
private readonly baseUrl: string;
private readonly targetExtractor: TargetExtractor; private readonly targetExtractor: TargetExtractor;
private readonly handler: HttpHandler;
private readonly allowedMethods: string[];
private readonly allMethods: boolean;
private readonly allowedPathNamesRegEx: RegExp[];
public constructor(args: RouterHandlerArgs) { public constructor(args: RouterHandlerArgs) {
super(); super(args);
this.baseUrl = ensureTrailingSlash(args.baseUrl);
this.targetExtractor = args.targetExtractor; this.targetExtractor = args.targetExtractor;
this.handler = args.handler;
this.allowedMethods = args.allowedMethods;
this.allMethods = args.allowedMethods.includes('*');
this.allowedPathNamesRegEx = args.allowedPathNames.map((pn): RegExp => new RegExp(pn, 'u'));
} }
public async canHandle(input: HttpHandlerInput): Promise<void> { public async canHandle(input: HttpHandlerInput): Promise<void> {
@ -43,20 +25,7 @@ export class RouterHandler extends HttpHandler {
if (!request.url) { if (!request.url) {
throw new BadRequestHttpError('Cannot handle request without a url'); throw new BadRequestHttpError('Cannot handle request without a url');
} }
if (!request.method) { const target = await this.targetExtractor.handleSafe({ request });
throw new BadRequestHttpError('Cannot handle request without a method'); await super.canHandleInput(input, request.method ?? 'UNKNOWN', target);
}
if (!this.allMethods && !this.allowedMethods.includes(request.method)) {
throw new MethodNotAllowedHttpError([ request.method ], `${request.method} is not allowed.`);
}
const pathName = await getRelativeUrl(this.baseUrl, request, this.targetExtractor);
if (!this.allowedPathNamesRegEx.some((regex): boolean => regex.test(pathName))) {
throw new NotFoundHttpError(`Cannot handle route ${pathName}`);
}
await this.handler.canHandle(input);
}
public async handle(input: HttpHandlerInput): Promise<void> {
await this.handler.handle(input);
} }
} }

View File

@ -0,0 +1,84 @@
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { BaseRouterHandlerArgs } from '../../../../src/server/util/BaseRouterHandler';
import { BaseRouterHandler } from '../../../../src/server/util/BaseRouterHandler';
import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
class SimpleRouterHandler extends BaseRouterHandler<AsyncHandler<{ method: string; target: ResourceIdentifier }>> {
public constructor(args: BaseRouterHandlerArgs<AsyncHandler<{ method: string; target: ResourceIdentifier }>>) {
super(args);
}
public async canHandle(input: { method: string; target: ResourceIdentifier }): Promise<void> {
await this.canHandleInput(input, input.method, input.target);
}
}
describe('A BaseRouterHandler', (): void => {
const baseUrl = 'http://example.com/';
const method = 'GET';
const target: ResourceIdentifier = { path: 'http://example.com/foo' };
let handler: jest.Mocked<AsyncHandler<{ method: string; target: ResourceIdentifier }>>;
let router: SimpleRouterHandler;
beforeEach(async(): Promise<void> => {
handler = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue('result'),
} as any;
router = new SimpleRouterHandler({
baseUrl,
handler,
allowedPathNames: [ '^/foo$', '^/bar$' ],
allowedMethods: [ 'GET', 'HEAD' ],
});
});
it('requires the correct method.', async(): Promise<void> => {
await expect(router.canHandle({ method: 'POST', target })).rejects.toThrow('POST is not allowed');
});
it('requires the path to match a given regex.', async(): Promise<void> => {
await expect(router.canHandle({ method, target: { path: 'http://example.com/baz' }}))
.rejects.toThrow('Cannot handle route /baz');
});
it('accepts valid input.', async(): Promise<void> => {
await expect(router.canHandle({ method, target })).resolves.toBeUndefined();
});
it('requires the source handler to accept the input.', async(): Promise<void> => {
handler.canHandle.mockRejectedValue(new Error('bad input'));
await expect(router.canHandle({ method, target })).rejects.toThrow('bad input');
});
it('accepts all methods if no restrictions are defined.', async(): Promise<void> => {
router = new SimpleRouterHandler({
baseUrl,
handler,
allowedPathNames: [ '^/foo$', '^/bar$' ],
});
await expect(router.canHandle({ method: 'POST', target })).resolves.toBeUndefined();
});
it('accepts all paths if no restrictions are defined.', async(): Promise<void> => {
router = new SimpleRouterHandler({
handler,
allowedMethods: [ 'GET', 'HEAD' ],
});
await expect(router.canHandle({ method, target: { path: 'http://example.com/baz' }})).resolves.toBeUndefined();
});
it('requires a baseUrl input if there is a path restriction.', async(): Promise<void> => {
expect((): any => new SimpleRouterHandler({
handler,
allowedPathNames: [ '^/foo$', '^/bar$' ],
})).toThrow('A value for allowedPathNames requires baseUrl to be defined.');
});
it('calls the source handler.', async(): Promise<void> => {
await expect(router.handle({ method, target })).resolves.toBe('result');
expect(handler.handle).toHaveBeenCalledTimes(1);
expect(handler.handle).toHaveBeenLastCalledWith({ method, target });
});
});

View File

@ -0,0 +1,44 @@
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { HttpRequest } from '../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../src/server/HttpResponse';
import type { OperationHttpHandler } from '../../../../src/server/OperationHttpHandler';
import { OperationRouterHandler } from '../../../../src/server/util/OperationRouterHandler';
describe('An OperationRouterHandler', (): void => {
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
let operation: Operation;
let handler: jest.Mocked<OperationHttpHandler>;
let router: OperationRouterHandler;
beforeEach(async(): Promise<void> => {
operation = {
method: 'GET',
target: { path: 'http://example.com/foo' },
preferences: {},
body: new BasicRepresentation(),
};
handler = {
canHandle: jest.fn(),
handle: jest.fn(),
} as any;
router = new OperationRouterHandler({
baseUrl: 'http://example.com/',
handler,
allowedPathNames: [ '^/foo$' ],
allowedMethods: [ 'GET' ],
});
});
it('passes the operation values.', async(): Promise<void> => {
await expect(router.canHandle({ operation, request, response })).resolves.toBeUndefined();
operation.method = 'POST';
await expect(router.canHandle({ operation, request, response })).rejects.toThrow('POST is not allowed.');
operation.method = 'GET';
operation.target = { path: 'http://example.com/wrong' };
await expect(router.canHandle({ operation, request, response })).rejects.toThrow('Cannot handle route /wrong');
});
});

View File

@ -1,131 +1,57 @@
import { createRequest, createResponse } from 'node-mocks-http'; import type { HttpRequest,
import type {
AsyncHandler,
HttpHandlerInput,
HttpRequest,
HttpResponse, HttpResponse,
TargetExtractor, TargetExtractor,
ResourceIdentifier, ResourceIdentifier,
RouterHandlerArgs, HttpHandler } from '../../../../src';
} from '../../../../src'; import { joinUrl } from '../../../../src';
import { guardStream, joinUrl } from '../../../../src';
import { RouterHandler } from '../../../../src/server/util/RouterHandler'; import { RouterHandler } from '../../../../src/server/util/RouterHandler';
import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler';
describe('A RouterHandler', (): void => { describe('A RouterHandler', (): void => {
const baseUrl = 'http://test.com/foo/'; const baseUrl = 'http://test.com/';
let targetExtractor: jest.Mocked<TargetExtractor>; let targetExtractor: jest.Mocked<TargetExtractor>;
let subHandler: AsyncHandler<any, any>; let request: HttpRequest;
let genericRequest: HttpRequest; const response: HttpResponse = {} as any;
let genericResponse: HttpResponse; let handler: jest.Mocked<HttpHandler>;
let genericInput: HttpHandlerInput; let router: RouterHandler;
let args: RouterHandlerArgs;
beforeEach((): void => { beforeEach((): void => {
request = { method: 'GET', url: '/test' } as any;
targetExtractor = { targetExtractor = {
handleSafe: jest.fn(({ request: req }): ResourceIdentifier => ({ path: joinUrl(baseUrl, req.url!) })), handleSafe: jest.fn(({ request: req }): ResourceIdentifier => ({ path: joinUrl(baseUrl, req.url!) })),
} as any; } as any;
subHandler = new StaticAsyncHandler(true, undefined); handler = {
canHandle: jest.fn(),
handle: jest.fn(),
} as any;
args = { router = new RouterHandler({
baseUrl, baseUrl,
targetExtractor, targetExtractor,
handler: subHandler, handler,
allowedMethods: [], allowedMethods: [ 'GET' ],
allowedPathNames: [], allowedPathNames: [ '^/test$' ],
}; });
genericRequest = guardStream(createRequest({
url: '/test',
}));
genericResponse = createResponse() as HttpResponse;
genericInput = {
request: genericRequest,
response: genericResponse,
};
}); });
it('calls the sub handler when handle is called.', async(): Promise<void> => { it('errors if there is no url.', async(): Promise<void> => {
args.allowedMethods = [ 'GET' ]; delete request.url;
args.allowedPathNames = [ '/test' ]; await expect(router.canHandle({ request, response }))
const handler = new RouterHandler(args); .rejects.toThrow('Cannot handle request without a url');
expect(await handler.handle(genericInput)).toBeUndefined();
}); });
it('throws an error if the request does not have a url.', async(): Promise<void> => { it('passes the request method.', async(): Promise<void> => {
args.allowedMethods = [ 'GET' ]; await expect(router.canHandle({ request, response })).resolves.toBeUndefined();
args.allowedPathNames = [ '/test' ]; request.method = 'POST';
const handler = new RouterHandler(args); await expect(router.canHandle({ request, response })).rejects.toThrow('POST is not allowed.');
const request = guardStream(createRequest()); delete request.method;
await expect(handler.canHandle({ await expect(router.canHandle({ request, response })).rejects.toThrow('UNKNOWN is not allowed.');
request,
response: genericResponse,
})).rejects.toThrow('Cannot handle request without a url');
}); });
it('throws an error if the request does not have a method.', async(): Promise<void> => { it('generates a ResourceIdentifier based on the url.', async(): Promise<void> => {
args.allowedMethods = [ 'GET' ]; await expect(router.canHandle({ request, response })).resolves.toBeUndefined();
args.allowedPathNames = [ '/test' ]; request.url = '/wrongTest';
const handler = new RouterHandler(args); await expect(router.canHandle({ request, response })).rejects.toThrow('Cannot handle route /wrongTest');
const request = guardStream(createRequest({
url: '/test',
}));
// @ts-expect-error manually set the method
request.method = undefined;
await expect(handler.canHandle({
request,
response: genericResponse,
})).rejects.toThrow('Cannot handle request without a method');
});
it('throws an error when there are no allowed methods or pathnames.', async(): Promise<void> => {
args.allowedMethods = [];
args.allowedPathNames = [];
const handler = new RouterHandler(args);
await expect(handler.canHandle(genericInput)).rejects.toThrow('GET is not allowed.');
});
it('throws an error when there are no allowed methods.', async(): Promise<void> => {
args.allowedMethods = [];
args.allowedPathNames = [ '/test' ];
const handler = new RouterHandler(args);
await expect(handler.canHandle(genericInput)).rejects.toThrow('GET is not allowed.');
});
it('throws an error when there are no allowed pathnames.', async(): Promise<void> => {
args.allowedMethods = [ 'GET' ];
args.allowedPathNames = [];
const handler = new RouterHandler(args);
await expect(handler.canHandle(genericInput)).rejects.toThrow('Cannot handle route /test');
});
it('throws an error if the RegEx string is not valid Regex.', async(): Promise<void> => {
args.allowedMethods = [ 'GET' ];
args.allowedPathNames = [ '[' ];
expect((): RouterHandler => new RouterHandler(args))
.toThrow('Invalid regular expression: /[/: Unterminated character class');
});
it('throws an error if all else is successful, but the sub handler cannot handle.', async(): Promise<void> => {
args.handler = new StaticAsyncHandler(false, undefined);
args.allowedMethods = [ 'GET' ];
args.allowedPathNames = [ '/test' ];
const handler = new RouterHandler(args);
await expect(handler.canHandle(genericInput)).rejects.toThrow('Not supported');
});
it('does not throw an error if the sub handler is successful.', async(): Promise<void> => {
args.allowedMethods = [ 'GET' ];
args.allowedPathNames = [ '/test' ];
const handler = new RouterHandler(args);
expect(await handler.canHandle(genericInput)).toBeUndefined();
});
it('supports * for all methods.', async(): Promise<void> => {
args.allowedMethods = [ '*' ];
args.allowedPathNames = [ '/test' ];
const handler = new RouterHandler(args);
expect(await handler.canHandle(genericInput)).toBeUndefined();
}); });
}); });