feat: Create conditions based on input headers

This commit is contained in:
Joachim Van Herwegen 2021-08-16 15:51:32 +02:00
parent 77d695c8b6
commit 20f783a581
26 changed files with 406 additions and 34 deletions

View File

@ -1,5 +1,6 @@
[ [
"Adapter", "Adapter",
"BasicConditions",
"BasicRepresentation", "BasicRepresentation",
"Error", "Error",
"EventEmitter", "EventEmitter",

View File

@ -11,6 +11,7 @@
}, },
"args_preferenceParser": { "@type": "AcceptPreferenceParser" }, "args_preferenceParser": { "@type": "AcceptPreferenceParser" },
"args_metadataParser": { "@id": "urn:solid-server:default:MetadataParser" }, "args_metadataParser": { "@id": "urn:solid-server:default:MetadataParser" },
"args_conditionsParser": { "@type": "BasicConditionsParser" },
"args_bodyParser": { "args_bodyParser": {
"@type": "WaterfallHandler", "@type": "WaterfallHandler",
"handlers": [ "handlers": [

View File

@ -83,6 +83,10 @@ export * from './ldp/auxiliary/RoutingAuxiliaryStrategy';
export * from './ldp/auxiliary/SuffixAuxiliaryIdentifierStrategy'; export * from './ldp/auxiliary/SuffixAuxiliaryIdentifierStrategy';
export * from './ldp/auxiliary/Validator'; export * from './ldp/auxiliary/Validator';
// LDP/HTTP/Conditions
export * from './ldp/http/conditions/BasicConditionsParser';
export * from './ldp/http/conditions/ConditionsParser';
// LDP/HTTP/Metadata // LDP/HTTP/Metadata
export * from './ldp/http/metadata/ConstantMetadataWriter'; export * from './ldp/http/metadata/ConstantMetadataWriter';
export * from './ldp/http/metadata/ContentTypeParser'; export * from './ldp/http/metadata/ContentTypeParser';
@ -258,6 +262,7 @@ export * from './storage/routing/RouterRule';
// Storage // Storage
export * from './storage/AtomicResourceStore'; export * from './storage/AtomicResourceStore';
export * from './storage/BaseResourceStore'; export * from './storage/BaseResourceStore';
export * from './storage/BasicConditions';
export * from './storage/Conditions'; export * from './storage/Conditions';
export * from './storage/DataAccessorBasedStore'; export * from './storage/DataAccessorBasedStore';
export * from './storage/IndexRepresentationStore'; export * from './storage/IndexRepresentationStore';

View File

@ -3,6 +3,7 @@ import { InternalServerError } from '../../util/errors/InternalServerError';
import type { Operation } from '../operations/Operation'; import type { Operation } from '../operations/Operation';
import { RepresentationMetadata } from '../representation/RepresentationMetadata'; import { RepresentationMetadata } from '../representation/RepresentationMetadata';
import type { BodyParser } from './BodyParser'; import type { BodyParser } from './BodyParser';
import type { ConditionsParser } from './conditions/ConditionsParser';
import type { MetadataParser } from './metadata/MetadataParser'; import type { MetadataParser } from './metadata/MetadataParser';
import type { PreferenceParser } from './PreferenceParser'; import type { PreferenceParser } from './PreferenceParser';
import { RequestParser } from './RequestParser'; import { RequestParser } from './RequestParser';
@ -15,17 +16,20 @@ export interface BasicRequestParserArgs {
targetExtractor: TargetExtractor; targetExtractor: TargetExtractor;
preferenceParser: PreferenceParser; preferenceParser: PreferenceParser;
metadataParser: MetadataParser; metadataParser: MetadataParser;
conditionsParser: ConditionsParser;
bodyParser: BodyParser; bodyParser: BodyParser;
} }
/** /**
* Creates an {@link Operation} from an incoming {@link HttpRequest} by aggregating the results * Creates an {@link Operation} from an incoming {@link HttpRequest} by aggregating the results
* of a {@link TargetExtractor}, {@link PreferenceParser}, {@link MetadataParser}, and {@link BodyParser}. * of a {@link TargetExtractor}, {@link PreferenceParser}, {@link MetadataParser},
* {@link ConditionsParser} and {@link BodyParser}.
*/ */
export class BasicRequestParser extends RequestParser { export class BasicRequestParser extends RequestParser {
private readonly targetExtractor!: TargetExtractor; private readonly targetExtractor!: TargetExtractor;
private readonly preferenceParser!: PreferenceParser; private readonly preferenceParser!: PreferenceParser;
private readonly metadataParser!: MetadataParser; private readonly metadataParser!: MetadataParser;
private readonly conditionsParser!: ConditionsParser;
private readonly bodyParser!: BodyParser; private readonly bodyParser!: BodyParser;
public constructor(args: BasicRequestParserArgs) { public constructor(args: BasicRequestParserArgs) {
@ -42,8 +46,9 @@ export class BasicRequestParser extends RequestParser {
const preferences = await this.preferenceParser.handleSafe({ request }); const preferences = await this.preferenceParser.handleSafe({ request });
const metadata = new RepresentationMetadata(target); const metadata = new RepresentationMetadata(target);
await this.metadataParser.handleSafe({ request, metadata }); await this.metadataParser.handleSafe({ request, metadata });
const conditions = await this.conditionsParser.handleSafe(request);
const body = await this.bodyParser.handleSafe({ request, metadata }); const body = await this.bodyParser.handleSafe({ request, metadata });
return { method, target, preferences, body }; return { method, target, preferences, conditions, body };
} }
} }

View File

@ -0,0 +1,63 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpRequest } from '../../../server/HttpRequest';
import type { BasicConditionsOptions } from '../../../storage/BasicConditions';
import { BasicConditions } from '../../../storage/BasicConditions';
import type { Conditions } from '../../../storage/Conditions';
import { ConditionsParser } from './ConditionsParser';
/**
* Creates a Conditions object based on the the following headers:
* - If-Modified-Since
* - If-Unmodified-Since
* - If-Match
* - If-None-Match
*
* Implementation based on RFC7232
*/
export class BasicConditionsParser extends ConditionsParser {
protected readonly logger = getLoggerFor(this);
public async handle(request: HttpRequest): Promise<Conditions | undefined> {
const options: BasicConditionsOptions = {
matchesETag: this.parseTagHeader(request, 'if-match'),
notMatchesETag: this.parseTagHeader(request, 'if-none-match'),
};
// A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field
// A recipient MUST ignore the If-Modified-Since header field ... if the request method is neither GET nor HEAD.
if (!options.notMatchesETag && (request.method === 'GET' || request.method === 'HEAD')) {
options.modifiedSince = this.parseDateHeader(request, 'if-modified-since');
}
// A recipient MUST ignore If-Unmodified-Since if the request contains an If-Match header field
if (!options.matchesETag) {
options.unmodifiedSince = this.parseDateHeader(request, 'if-unmodified-since');
}
// Only return a Conditions object if there is at least one condition; undefined otherwise
this.logger.debug(`Found the following conditions: ${JSON.stringify(options)}`);
if (Object.values(options).some((val): boolean => typeof val !== 'undefined')) {
return new BasicConditions(options);
}
}
/**
* Converts a request header containing a datetime string to an actual Date object.
* Undefined if there is no value for the given header name.
*/
private parseDateHeader(request: HttpRequest, header: 'if-modified-since' | 'if-unmodified-since'): Date | undefined {
const headerVal = request.headers[header];
if (headerVal) {
const timestamp = Date.parse(headerVal);
return Number.isNaN(timestamp) ? undefined : new Date(timestamp);
}
}
/**
* Converts a request header containing ETags to an array of ETags.
* Undefined if there is no value for the given header name.
*/
private parseTagHeader(request: HttpRequest, header: 'if-match' | 'if-none-match'): string[] | undefined {
return request.headers[header]?.trim().split(/\s*,\s*/u);
}
}

View File

@ -0,0 +1,8 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import type { Conditions } from '../../../storage/Conditions';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
/**
* Creates a Conditions object based on the input HttpRequest.
*/
export abstract class ConditionsParser extends AsyncHandler<HttpRequest, Conditions | undefined> {}

View File

@ -24,7 +24,7 @@ export class DeleteOperationHandler extends OperationHandler {
} }
public async handle(input: Operation): Promise<ResponseDescription> { public async handle(input: Operation): Promise<ResponseDescription> {
await this.store.deleteResource(input.target); await this.store.deleteResource(input.target, input.conditions);
return new ResetResponseDescription(); return new ResetResponseDescription();
} }
} }

View File

@ -24,7 +24,7 @@ export class GetOperationHandler extends OperationHandler {
} }
public async handle(input: Operation): Promise<ResponseDescription> { public async handle(input: Operation): Promise<ResponseDescription> {
const body = await this.store.getRepresentation(input.target, input.preferences); const body = await this.store.getRepresentation(input.target, input.preferences, input.conditions);
input.authorization?.addMetadata(body.metadata); input.authorization?.addMetadata(body.metadata);

View File

@ -24,7 +24,7 @@ export class HeadOperationHandler extends OperationHandler {
} }
public async handle(input: Operation): Promise<ResponseDescription> { public async handle(input: Operation): Promise<ResponseDescription> {
const body = await this.store.getRepresentation(input.target, input.preferences); const body = await this.store.getRepresentation(input.target, input.preferences, input.conditions);
// Close the Readable as we will not return it. // Close the Readable as we will not return it.
body.data.destroy(); body.data.destroy();

View File

@ -1,4 +1,5 @@
import type { Authorization } from '../../authorization/Authorization'; import type { Authorization } from '../../authorization/Authorization';
import type { Conditions } from '../../storage/Conditions';
import type { Representation } from '../representation/Representation'; import type { Representation } from '../representation/Representation';
import type { RepresentationPreferences } from '../representation/RepresentationPreferences'; import type { RepresentationPreferences } from '../representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
@ -19,6 +20,10 @@ export interface Operation {
* Representation preferences of the response. Will be empty if there are none. * Representation preferences of the response. Will be empty if there are none.
*/ */
preferences: RepresentationPreferences; preferences: RepresentationPreferences;
/**
* Conditions the resource must fulfill for a valid operation.
*/
conditions?: Conditions;
/** /**
* This value will be set if the Operation was authorized by an Authorizer. * This value will be set if the Operation was authorized by an Authorizer.
*/ */

View File

@ -36,7 +36,7 @@ export class PatchOperationHandler extends OperationHandler {
this.logger.warn('No Content-Type header specified on PATCH request'); this.logger.warn('No Content-Type header specified on PATCH request');
throw new BadRequestHttpError('No Content-Type header specified on PATCH request'); throw new BadRequestHttpError('No Content-Type header specified on PATCH request');
} }
await this.store.modifyResource(input.target, input.body as Patch); await this.store.modifyResource(input.target, input.body as Patch, input.conditions);
return new ResetResponseDescription(); return new ResetResponseDescription();
} }
} }

View File

@ -35,7 +35,7 @@ export class PostOperationHandler extends OperationHandler {
this.logger.warn('No Content-Type header specified on POST request'); this.logger.warn('No Content-Type header specified on POST request');
throw new BadRequestHttpError('No Content-Type header specified on POST request'); throw new BadRequestHttpError('No Content-Type header specified on POST request');
} }
const identifier = await this.store.addResource(input.target, input.body); const identifier = await this.store.addResource(input.target, input.body, input.conditions);
return new CreatedResponseDescription(identifier); return new CreatedResponseDescription(identifier);
} }
} }

View File

@ -35,7 +35,7 @@ export class PutOperationHandler extends OperationHandler {
this.logger.warn('No Content-Type header specified on PUT request'); this.logger.warn('No Content-Type header specified on PUT request');
throw new BadRequestHttpError('No Content-Type header specified on PUT request'); throw new BadRequestHttpError('No Content-Type header specified on PUT request');
} }
await this.store.setRepresentation(input.target, input.body); await this.store.setRepresentation(input.target, input.body, input.conditions);
return new ResetResponseDescription(); return new ResetResponseDescription();
} }
} }

View File

@ -0,0 +1,69 @@
import type { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
import { DC } from '../util/Vocabularies';
import { getETag } from './Conditions';
import type { Conditions } from './Conditions';
export interface BasicConditionsOptions {
matchesETag?: string[];
notMatchesETag?: string[];
modifiedSince?: Date;
unmodifiedSince?: Date;
}
/**
* Stores all the relevant Conditions values and matches them based on RFC7232.
*/
export class BasicConditions implements Conditions {
public readonly matchesETag?: string[];
public readonly notMatchesETag?: string[];
public readonly modifiedSince?: Date;
public readonly unmodifiedSince?: Date;
public constructor(options: BasicConditionsOptions) {
this.matchesETag = options.matchesETag;
this.notMatchesETag = options.notMatchesETag;
this.modifiedSince = options.modifiedSince;
this.unmodifiedSince = options.unmodifiedSince;
}
public matchesMetadata(metadata?: RepresentationMetadata): boolean {
if (!metadata) {
// RFC7232: ...If-Match... If the field-value is "*", the condition is false if the origin server
// does not have a current representation for the target resource.
return !this.matchesETag?.includes('*');
}
const modified = metadata.get(DC.terms.modified);
const modifiedDate = modified ? new Date(modified.value) : undefined;
const etag = getETag(metadata);
return this.matches(etag, modifiedDate);
}
public matches(eTag?: string, lastModified?: Date): boolean {
// RFC7232: ...If-None-Match... If the field-value is "*", the condition is false if the origin server
// has a current representation for the target resource.
if (this.notMatchesETag?.includes('*')) {
return false;
}
if (eTag) {
if (this.matchesETag && !this.matchesETag.includes(eTag) && !this.matchesETag.includes('*')) {
return false;
}
if (this.notMatchesETag?.includes(eTag)) {
return false;
}
}
if (lastModified) {
if (this.modifiedSince && lastModified < this.modifiedSince) {
return false;
}
if (this.unmodifiedSince && lastModified > this.unmodifiedSince) {
return false;
}
}
return true;
}
}

View File

@ -24,11 +24,13 @@ export interface Conditions {
/** /**
* Checks validity based on the given metadata. * Checks validity based on the given metadata.
* @param metadata - Metadata of the representation. * @param metadata - Metadata of the representation. Undefined if the resource does not exist.
*/ */
matchesMetadata: (metadata: RepresentationMetadata) => boolean; matchesMetadata: (metadata?: RepresentationMetadata) => boolean;
/** /**
* Checks validity based on the given ETag and/or date. * Checks validity based on the given ETag and/or date.
* This function assumes the resource being checked exists.
* If not, the `matchesMetadata` function should be used.
* @param eTag - Condition based on ETag. * @param eTag - Condition based on ETag.
* @param lastModified - Condition based on last modified date. * @param lastModified - Condition based on last modified date.
*/ */

View File

@ -2,19 +2,24 @@ import { Readable } from 'stream';
import arrayifyStream from 'arrayify-stream'; import arrayifyStream from 'arrayify-stream';
import { AcceptPreferenceParser } from '../../src/ldp/http/AcceptPreferenceParser'; import { AcceptPreferenceParser } from '../../src/ldp/http/AcceptPreferenceParser';
import { BasicRequestParser } from '../../src/ldp/http/BasicRequestParser'; import { BasicRequestParser } from '../../src/ldp/http/BasicRequestParser';
import { BasicConditionsParser } from '../../src/ldp/http/conditions/BasicConditionsParser';
import { ContentTypeParser } from '../../src/ldp/http/metadata/ContentTypeParser'; import { ContentTypeParser } from '../../src/ldp/http/metadata/ContentTypeParser';
import { OriginalUrlExtractor } from '../../src/ldp/http/OriginalUrlExtractor'; import { OriginalUrlExtractor } from '../../src/ldp/http/OriginalUrlExtractor';
import { RawBodyParser } from '../../src/ldp/http/RawBodyParser'; import { RawBodyParser } from '../../src/ldp/http/RawBodyParser';
import { RepresentationMetadata } from '../../src/ldp/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../src/ldp/representation/RepresentationMetadata';
import type { HttpRequest } from '../../src/server/HttpRequest'; import type { HttpRequest } from '../../src/server/HttpRequest';
import { BasicConditions } from '../../src/storage/BasicConditions';
import { guardedStreamFrom } from '../../src/util/StreamUtil'; import { guardedStreamFrom } from '../../src/util/StreamUtil';
describe('A BasicRequestParser with simple input parsers', (): void => { describe('A BasicRequestParser with simple input parsers', (): void => {
const targetExtractor = new OriginalUrlExtractor(); const targetExtractor = new OriginalUrlExtractor();
const preferenceParser = new AcceptPreferenceParser(); const preferenceParser = new AcceptPreferenceParser();
const metadataParser = new ContentTypeParser(); const metadataParser = new ContentTypeParser();
const conditionsParser = new BasicConditionsParser();
const bodyParser = new RawBodyParser(); const bodyParser = new RawBodyParser();
const requestParser = new BasicRequestParser({ targetExtractor, preferenceParser, metadataParser, bodyParser }); const requestParser = new BasicRequestParser(
{ targetExtractor, preferenceParser, metadataParser, conditionsParser, bodyParser },
);
it('can parse an incoming request.', async(): Promise<void> => { it('can parse an incoming request.', async(): Promise<void> => {
const request = guardedStreamFrom([ '<http://test.com/s> <http://test.com/p> <http://test.com/o>.' ]) as HttpRequest; const request = guardedStreamFrom([ '<http://test.com/s> <http://test.com/p> <http://test.com/o>.' ]) as HttpRequest;
@ -24,6 +29,8 @@ describe('A BasicRequestParser with simple input parsers', (): void => {
accept: 'text/turtle; q=0.8', accept: 'text/turtle; q=0.8',
'accept-language': 'en-gb, en;q=0.5', 'accept-language': 'en-gb, en;q=0.5',
'content-type': 'text/turtle', 'content-type': 'text/turtle',
'if-unmodified-since': 'Wed, 21 Oct 2015 07:28:00 UTC',
'if-none-match': '12345',
'transfer-encoding': 'chunked', 'transfer-encoding': 'chunked',
host: 'test.com', host: 'test.com',
}; };
@ -36,6 +43,10 @@ describe('A BasicRequestParser with simple input parsers', (): void => {
type: { 'text/turtle': 0.8 }, type: { 'text/turtle': 0.8 },
language: { 'en-gb': 1, en: 0.5 }, language: { 'en-gb': 1, en: 0.5 },
}, },
conditions: new BasicConditions({
unmodifiedSince: new Date('2015-10-21T07:28:00.000Z'),
notMatchesETag: [ '12345' ],
}),
body: { body: {
data: expect.any(Readable), data: expect.any(Readable),
binary: true, binary: true,

View File

@ -1,5 +1,6 @@
import { BasicRequestParser } from '../../../../src/ldp/http/BasicRequestParser'; import { BasicRequestParser } from '../../../../src/ldp/http/BasicRequestParser';
import type { BodyParser } from '../../../../src/ldp/http/BodyParser'; import type { BodyParser } from '../../../../src/ldp/http/BodyParser';
import type { ConditionsParser } from '../../../../src/ldp/http/conditions/ConditionsParser';
import type { MetadataParser } from '../../../../src/ldp/http/metadata/MetadataParser'; import type { MetadataParser } from '../../../../src/ldp/http/metadata/MetadataParser';
import type { PreferenceParser } from '../../../../src/ldp/http/PreferenceParser'; import type { PreferenceParser } from '../../../../src/ldp/http/PreferenceParser';
import type { TargetExtractor } from '../../../../src/ldp/http/TargetExtractor'; import type { TargetExtractor } from '../../../../src/ldp/http/TargetExtractor';
@ -10,6 +11,7 @@ describe('A BasicRequestParser', (): void => {
let targetExtractor: TargetExtractor; let targetExtractor: TargetExtractor;
let preferenceParser: PreferenceParser; let preferenceParser: PreferenceParser;
let metadataParser: MetadataParser; let metadataParser: MetadataParser;
let conditionsParser: ConditionsParser;
let bodyParser: BodyParser; let bodyParser: BodyParser;
let requestParser: BasicRequestParser; let requestParser: BasicRequestParser;
@ -17,8 +19,11 @@ describe('A BasicRequestParser', (): void => {
targetExtractor = new StaticAsyncHandler(true, 'target' as any); targetExtractor = new StaticAsyncHandler(true, 'target' as any);
preferenceParser = new StaticAsyncHandler(true, 'preference' as any); preferenceParser = new StaticAsyncHandler(true, 'preference' as any);
metadataParser = new StaticAsyncHandler(true, undefined); metadataParser = new StaticAsyncHandler(true, undefined);
conditionsParser = new StaticAsyncHandler(true, 'conditions' as any);
bodyParser = new StaticAsyncHandler(true, 'body' as any); bodyParser = new StaticAsyncHandler(true, 'body' as any);
requestParser = new BasicRequestParser({ targetExtractor, preferenceParser, metadataParser, bodyParser }); requestParser = new BasicRequestParser(
{ targetExtractor, preferenceParser, metadataParser, conditionsParser, bodyParser },
);
}); });
it('can handle any input.', async(): Promise<void> => { it('can handle any input.', async(): Promise<void> => {
@ -36,6 +41,7 @@ describe('A BasicRequestParser', (): void => {
method: 'GET', method: 'GET',
target: 'target', target: 'target',
preferences: 'preference', preferences: 'preference',
conditions: 'conditions',
body: { data: 'body', metadata: new RepresentationMetadata('target') }, body: { data: 'body', metadata: new RepresentationMetadata('target') },
}); });
}); });

View File

@ -0,0 +1,60 @@
import { BasicConditionsParser } from '../../../../../src/ldp/http/conditions/BasicConditionsParser';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
describe('A BasicConditionsParser', (): void => {
const dateString = 'Wed, 21 Oct 2015 07:28:00 UTC';
const date = new Date('2015-10-21T07:28:00.000Z');
let request: HttpRequest;
const parser = new BasicConditionsParser();
beforeEach(async(): Promise<void> => {
request = { headers: {}, method: 'GET' } as HttpRequest;
});
it('returns undefined if there are no relevant headers.', async(): Promise<void> => {
await expect(parser.handleSafe(request)).resolves.toBeUndefined();
});
it('parses the if-modified-since header.', async(): Promise<void> => {
request.headers['if-modified-since'] = dateString;
await expect(parser.handleSafe(request)).resolves.toEqual({ modifiedSince: date });
});
it('parses the if-unmodified-since header.', async(): Promise<void> => {
request.headers['if-unmodified-since'] = dateString;
await expect(parser.handleSafe(request)).resolves.toEqual({ unmodifiedSince: date });
});
it('parses the if-match header.', async(): Promise<void> => {
request.headers['if-match'] = '"1234567", "abcdefg"';
await expect(parser.handleSafe(request)).resolves.toEqual({ matchesETag: [ '"1234567"', '"abcdefg"' ]});
});
it('parses the if-none-match header.', async(): Promise<void> => {
request.headers['if-none-match'] = '*';
await expect(parser.handleSafe(request)).resolves.toEqual({ notMatchesETag: [ '*' ]});
});
it('does not parse the if-modified-since header if there is an if-none-match header.', async(): Promise<void> => {
request.headers['if-modified-since'] = dateString;
request.headers['if-none-match'] = '*';
await expect(parser.handleSafe(request)).resolves.toEqual({ notMatchesETag: [ '*' ]});
});
it('only parses the if-modified-since header for GET and HEAD requests.', async(): Promise<void> => {
request.headers['if-modified-since'] = dateString;
request.method = 'PUT';
await expect(parser.handleSafe(request)).resolves.toBeUndefined();
});
it('does not parse the if-unmodified-since header if there is an if-match header.', async(): Promise<void> => {
request.headers['if-unmodified-since'] = dateString;
request.headers['if-match'] = '*';
await expect(parser.handleSafe(request)).resolves.toEqual({ matchesETag: [ '*' ]});
});
it('ignores invalid dates.', async(): Promise<void> => {
request.headers['if-modified-since'] = 'notADate';
await expect(parser.handleSafe(request)).resolves.toBeUndefined();
});
});

View File

@ -1,9 +1,11 @@
import { DeleteOperationHandler } from '../../../../src/ldp/operations/DeleteOperationHandler'; import { DeleteOperationHandler } from '../../../../src/ldp/operations/DeleteOperationHandler';
import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Operation } from '../../../../src/ldp/operations/Operation';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A DeleteOperationHandler', (): void => { describe('A DeleteOperationHandler', (): void => {
const conditions = new BasicConditions({});
const store = {} as unknown as ResourceStore; const store = {} as unknown as ResourceStore;
const handler = new DeleteOperationHandler(store); const handler = new DeleteOperationHandler(store);
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -16,9 +18,9 @@ describe('A DeleteOperationHandler', (): void => {
}); });
it('deletes the resource from the store and returns the correct response.', async(): Promise<void> => { it('deletes the resource from the store and returns the correct response.', async(): Promise<void> => {
const result = await handler.handle({ target: { path: 'url' }} as Operation); const result = await handler.handle({ target: { path: 'url' }, conditions } as Operation);
expect(store.deleteResource).toHaveBeenCalledTimes(1); expect(store.deleteResource).toHaveBeenCalledTimes(1);
expect(store.deleteResource).toHaveBeenLastCalledWith({ path: 'url' }); expect(store.deleteResource).toHaveBeenLastCalledWith({ path: 'url' }, conditions);
expect(result.statusCode).toBe(205); expect(result.statusCode).toBe(205);
expect(result.metadata).toBeUndefined(); expect(result.metadata).toBeUndefined();
expect(result.data).toBeUndefined(); expect(result.data).toBeUndefined();

View File

@ -2,15 +2,24 @@ import type { Authorization } from '../../../../src/authorization/Authorization'
import { GetOperationHandler } from '../../../../src/ldp/operations/GetOperationHandler'; import { GetOperationHandler } from '../../../../src/ldp/operations/GetOperationHandler';
import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Operation } from '../../../../src/ldp/operations/Operation';
import type { Representation } from '../../../../src/ldp/representation/Representation'; import type { Representation } from '../../../../src/ldp/representation/Representation';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A GetOperationHandler', (): void => { describe('A GetOperationHandler', (): void => {
const store = { const conditions = new BasicConditions({});
getRepresentation: async(): Promise<Representation> => const preferences = {};
({ binary: false, data: 'data', metadata: 'metadata' } as any), let store: ResourceStore;
let handler: GetOperationHandler;
beforeEach(async(): Promise<void> => {
store = {
getRepresentation: jest.fn(async(): Promise<Representation> =>
({ binary: false, data: 'data', metadata: 'metadata' } as any)),
} as unknown as ResourceStore; } as unknown as ResourceStore;
const handler = new GetOperationHandler(store);
handler = new GetOperationHandler(store);
});
it('only supports GET operations.', async(): Promise<void> => { it('only supports GET operations.', async(): Promise<void> => {
await expect(handler.canHandle({ method: 'GET' } as Operation)).resolves.toBeUndefined(); await expect(handler.canHandle({ method: 'GET' } as Operation)).resolves.toBeUndefined();
@ -18,16 +27,22 @@ describe('A GetOperationHandler', (): void => {
}); });
it('returns the representation from the store with the correct response.', async(): Promise<void> => { it('returns the representation from the store with the correct response.', async(): Promise<void> => {
const result = await handler.handle({ target: { path: 'url' }} as Operation); const result = await handler.handle({ target: { path: 'url' }, preferences, conditions } as Operation);
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(result.metadata).toBe('metadata'); expect(result.metadata).toBe('metadata');
expect(result.data).toBe('data'); expect(result.data).toBe('data');
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions);
}); });
it('adds authorization metadata in case the operation is an AuthorizedOperation.', async(): Promise<void> => { it('adds authorization metadata in case the operation is an AuthorizedOperation.', async(): Promise<void> => {
const authorization: Authorization = { addMetadata: jest.fn() }; const authorization: Authorization = { addMetadata: jest.fn() };
const result = await handler.handle({ target: { path: 'url' }, authorization } as Operation); const result = await handler.handle(
{ target: { path: 'url' }, preferences, conditions, authorization } as Operation,
);
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions);
expect(authorization.addMetadata).toHaveBeenCalledTimes(1); expect(authorization.addMetadata).toHaveBeenCalledTimes(1);
expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata'); expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata');
}); });

View File

@ -3,10 +3,13 @@ import type { Authorization } from '../../../../src/authorization/Authorization'
import { HeadOperationHandler } from '../../../../src/ldp/operations/HeadOperationHandler'; import { HeadOperationHandler } from '../../../../src/ldp/operations/HeadOperationHandler';
import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Operation } from '../../../../src/ldp/operations/Operation';
import type { Representation } from '../../../../src/ldp/representation/Representation'; import type { Representation } from '../../../../src/ldp/representation/Representation';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A HeadOperationHandler', (): void => { describe('A HeadOperationHandler', (): void => {
const conditions = new BasicConditions({});
const preferences = {};
let store: ResourceStore; let store: ResourceStore;
let handler: HeadOperationHandler; let handler: HeadOperationHandler;
let data: Readable; let data: Readable;
@ -14,8 +17,8 @@ describe('A HeadOperationHandler', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
data = { destroy: jest.fn() } as any; data = { destroy: jest.fn() } as any;
store = { store = {
getRepresentation: async(): Promise<Representation> => getRepresentation: jest.fn(async(): Promise<Representation> =>
({ binary: false, data, metadata: 'metadata' } as any), ({ binary: false, data, metadata: 'metadata' } as any)),
} as any; } as any;
handler = new HeadOperationHandler(store); handler = new HeadOperationHandler(store);
}); });
@ -27,17 +30,23 @@ describe('A HeadOperationHandler', (): void => {
}); });
it('returns the representation from the store with the correct response.', async(): Promise<void> => { it('returns the representation from the store with the correct response.', async(): Promise<void> => {
const result = await handler.handle({ target: { path: 'url' }} as Operation); const result = await handler.handle({ target: { path: 'url' }, preferences, conditions } as Operation);
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(result.metadata).toBe('metadata'); expect(result.metadata).toBe('metadata');
expect(result.data).toBeUndefined(); expect(result.data).toBeUndefined();
expect(data.destroy).toHaveBeenCalledTimes(1); expect(data.destroy).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions);
}); });
it('adds authorization metadata in case the operation is an AuthorizedOperation.', async(): Promise<void> => { it('adds authorization metadata in case the operation is an AuthorizedOperation.', async(): Promise<void> => {
const authorization: Authorization = { addMetadata: jest.fn() }; const authorization: Authorization = { addMetadata: jest.fn() };
const result = await handler.handle({ target: { path: 'url' }, authorization } as Operation); const result = await handler.handle(
{ target: { path: 'url' }, preferences, conditions, authorization } as Operation,
);
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions);
expect(authorization.addMetadata).toHaveBeenCalledTimes(1); expect(authorization.addMetadata).toHaveBeenCalledTimes(1);
expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata'); expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata');
}); });

View File

@ -1,11 +1,13 @@
import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Operation } from '../../../../src/ldp/operations/Operation';
import { PatchOperationHandler } from '../../../../src/ldp/operations/PatchOperationHandler'; import { PatchOperationHandler } from '../../../../src/ldp/operations/PatchOperationHandler';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A PatchOperationHandler', (): void => { describe('A PatchOperationHandler', (): void => {
const conditions = new BasicConditions({});
const store = {} as unknown as ResourceStore; const store = {} as unknown as ResourceStore;
const handler = new PatchOperationHandler(store); const handler = new PatchOperationHandler(store);
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -25,9 +27,9 @@ describe('A PatchOperationHandler', (): void => {
it('deletes the resource from the store and returns the correct response.', async(): Promise<void> => { it('deletes the resource from the store and returns the correct response.', async(): Promise<void> => {
const metadata = new RepresentationMetadata('text/turtle'); const metadata = new RepresentationMetadata('text/turtle');
const result = await handler.handle({ target: { path: 'url' }, body: { metadata }} as Operation); const result = await handler.handle({ target: { path: 'url' }, body: { metadata }, conditions } as Operation);
expect(store.modifyResource).toHaveBeenCalledTimes(1); expect(store.modifyResource).toHaveBeenCalledTimes(1);
expect(store.modifyResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }); expect(store.modifyResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions);
expect(result.statusCode).toBe(205); expect(result.statusCode).toBe(205);
expect(result.metadata).toBeUndefined(); expect(result.metadata).toBeUndefined();
expect(result.data).toBeUndefined(); expect(result.data).toBeUndefined();

View File

@ -2,16 +2,23 @@ import type { Operation } from '../../../../src/ldp/operations/Operation';
import { PostOperationHandler } from '../../../../src/ldp/operations/PostOperationHandler'; import { PostOperationHandler } from '../../../../src/ldp/operations/PostOperationHandler';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { SOLID_HTTP } from '../../../../src/util/Vocabularies'; import { SOLID_HTTP } from '../../../../src/util/Vocabularies';
describe('A PostOperationHandler', (): void => { describe('A PostOperationHandler', (): void => {
const store = { const conditions = new BasicConditions({});
addResource: async(): Promise<ResourceIdentifier> => ({ path: 'newPath' } as ResourceIdentifier), let store: ResourceStore;
let handler: PostOperationHandler;
beforeEach(async(): Promise<void> => {
store = {
addResource: jest.fn(async(): Promise<ResourceIdentifier> => ({ path: 'newPath' } as ResourceIdentifier)),
} as unknown as ResourceStore; } as unknown as ResourceStore;
const handler = new PostOperationHandler(store); handler = new PostOperationHandler(store);
});
it('only supports POST operations.', async(): Promise<void> => { it('only supports POST operations.', async(): Promise<void> => {
await expect(handler.canHandle({ method: 'POST', body: { }} as Operation)) await expect(handler.canHandle({ method: 'POST', body: { }} as Operation))
@ -28,10 +35,14 @@ describe('A PostOperationHandler', (): void => {
it('adds the given representation to the store and returns the correct response.', async(): Promise<void> => { it('adds the given representation to the store and returns the correct response.', async(): Promise<void> => {
const metadata = new RepresentationMetadata('text/turtle'); const metadata = new RepresentationMetadata('text/turtle');
const result = await handler.handle({ method: 'POST', body: { metadata }} as Operation); const result = await handler.handle(
{ method: 'POST', target: { path: 'url' }, body: { metadata }, conditions } as Operation,
);
expect(result.statusCode).toBe(201); expect(result.statusCode).toBe(201);
expect(result.metadata).toBeInstanceOf(RepresentationMetadata); expect(result.metadata).toBeInstanceOf(RepresentationMetadata);
expect(result.metadata?.get(SOLID_HTTP.location)?.value).toBe('newPath'); expect(result.metadata?.get(SOLID_HTTP.location)?.value).toBe('newPath');
expect(result.data).toBeUndefined(); expect(result.data).toBeUndefined();
expect(store.addResource).toHaveBeenCalledTimes(1);
expect(store.addResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions);
}); });
}); });

View File

@ -1,11 +1,13 @@
import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Operation } from '../../../../src/ldp/operations/Operation';
import { PutOperationHandler } from '../../../../src/ldp/operations/PutOperationHandler'; import { PutOperationHandler } from '../../../../src/ldp/operations/PutOperationHandler';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A PutOperationHandler', (): void => { describe('A PutOperationHandler', (): void => {
const conditions = new BasicConditions({});
const store = {} as unknown as ResourceStore; const store = {} as unknown as ResourceStore;
const handler = new PutOperationHandler(store); const handler = new PutOperationHandler(store);
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -26,9 +28,9 @@ describe('A PutOperationHandler', (): void => {
it('sets the representation in the store and returns the correct response.', async(): Promise<void> => { it('sets the representation in the store and returns the correct response.', async(): Promise<void> => {
const metadata = new RepresentationMetadata('text/turtle'); const metadata = new RepresentationMetadata('text/turtle');
const result = await handler.handle({ target: { path: 'url' }, body: { metadata }} as Operation); const result = await handler.handle({ target: { path: 'url' }, body: { metadata }, conditions } as Operation);
expect(store.setRepresentation).toHaveBeenCalledTimes(1); expect(store.setRepresentation).toHaveBeenCalledTimes(1);
expect(store.setRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }); expect(store.setRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions);
expect(result.statusCode).toBe(205); expect(result.statusCode).toBe(205);
expect(result.metadata).toBeUndefined(); expect(result.metadata).toBeUndefined();
expect(result.data).toBeUndefined(); expect(result.data).toBeUndefined();

View File

@ -0,0 +1,78 @@
import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
import { BasicConditions } from '../../../src/storage/BasicConditions';
import { getETag } from '../../../src/storage/Conditions';
import { DC } from '../../../src/util/Vocabularies';
describe('A BasicConditions', (): void => {
const now = new Date(2020, 10, 20);
const tomorrow = new Date(2020, 10, 21);
const yesterday = new Date(2020, 10, 19);
const eTags = [ '123456', 'abcdefg' ];
it('copies the input parameters.', async(): Promise<void> => {
const options = { matchesETag: eTags, notMatchesETag: eTags, modifiedSince: now, unmodifiedSince: now };
expect(new BasicConditions(options)).toMatchObject(options);
});
it('always returns false if notMatchesETag contains *.', async(): Promise<void> => {
const conditions = new BasicConditions({ notMatchesETag: [ '*' ]});
expect(conditions.matches()).toBe(false);
});
it('requires matchesETag to contain the provided ETag.', async(): Promise<void> => {
const conditions = new BasicConditions({ matchesETag: [ '1234' ]});
expect(conditions.matches('abcd')).toBe(false);
expect(conditions.matches('1234')).toBe(true);
});
it('supports all ETags if matchesETag contains *.', async(): Promise<void> => {
const conditions = new BasicConditions({ matchesETag: [ '*' ]});
expect(conditions.matches('abcd')).toBe(true);
expect(conditions.matches('1234')).toBe(true);
});
it('requires notMatchesETag to not contain the provided ETag.', async(): Promise<void> => {
const conditions = new BasicConditions({ notMatchesETag: [ '1234' ]});
expect(conditions.matches('1234')).toBe(false);
expect(conditions.matches('abcd')).toBe(true);
});
it('requires lastModified to be after modifiedSince.', async(): Promise<void> => {
const conditions = new BasicConditions({ modifiedSince: now });
expect(conditions.matches(undefined, yesterday)).toBe(false);
expect(conditions.matches(undefined, tomorrow)).toBe(true);
});
it('requires lastModified to be before unmodifiedSince.', async(): Promise<void> => {
const conditions = new BasicConditions({ unmodifiedSince: now });
expect(conditions.matches(undefined, tomorrow)).toBe(false);
expect(conditions.matches(undefined, yesterday)).toBe(true);
});
it('can match based on the last modified date in the metadata.', async(): Promise<void> => {
const metadata = new RepresentationMetadata({ [DC.modified]: now.toISOString() });
const conditions = new BasicConditions({
modifiedSince: yesterday,
unmodifiedSince: tomorrow,
matchesETag: [ getETag(metadata)! ],
notMatchesETag: [ '123456' ],
});
expect(conditions.matchesMetadata(metadata)).toBe(true);
});
it('matches if no date is found in the metadata.', async(): Promise<void> => {
const metadata = new RepresentationMetadata();
const conditions = new BasicConditions({
modifiedSince: yesterday,
unmodifiedSince: tomorrow,
matchesETag: [ getETag(metadata)! ],
notMatchesETag: [ '123456' ],
});
expect(conditions.matchesMetadata(metadata)).toBe(true);
});
it('checks if matchesETag contains * for resources that do not exist.', async(): Promise<void> => {
expect(new BasicConditions({ matchesETag: [ '*' ]}).matchesMetadata()).toBe(false);
expect(new BasicConditions({}).matchesMetadata()).toBe(true);
});
});

View File

@ -0,0 +1,17 @@
import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
import { getETag } from '../../../src/storage/Conditions';
import { DC } from '../../../src/util/Vocabularies';
describe('Conditions', (): void => {
describe('#getETag', (): void => {
it('creates an ETag based on the date last modified.', async(): Promise<void> => {
const now = new Date();
const metadata = new RepresentationMetadata({ [DC.modified]: now.toISOString() });
expect(getETag(metadata)).toBe(`"${now.getTime()}"`);
});
it('returns undefined if no date was found.', async(): Promise<void> => {
expect(getETag(new RepresentationMetadata())).toBeUndefined();
});
});
});