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",
"BasicConditions",
"BasicRepresentation",
"Error",
"EventEmitter",

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { InternalServerError } from '../../util/errors/InternalServerError';
import type { Operation } from '../operations/Operation';
import { RepresentationMetadata } from '../representation/RepresentationMetadata';
import type { BodyParser } from './BodyParser';
import type { ConditionsParser } from './conditions/ConditionsParser';
import type { MetadataParser } from './metadata/MetadataParser';
import type { PreferenceParser } from './PreferenceParser';
import { RequestParser } from './RequestParser';
@ -15,17 +16,20 @@ export interface BasicRequestParserArgs {
targetExtractor: TargetExtractor;
preferenceParser: PreferenceParser;
metadataParser: MetadataParser;
conditionsParser: ConditionsParser;
bodyParser: BodyParser;
}
/**
* 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 {
private readonly targetExtractor!: TargetExtractor;
private readonly preferenceParser!: PreferenceParser;
private readonly metadataParser!: MetadataParser;
private readonly conditionsParser!: ConditionsParser;
private readonly bodyParser!: BodyParser;
public constructor(args: BasicRequestParserArgs) {
@ -42,8 +46,9 @@ export class BasicRequestParser extends RequestParser {
const preferences = await this.preferenceParser.handleSafe({ request });
const metadata = new RepresentationMetadata(target);
await this.metadataParser.handleSafe({ request, metadata });
const conditions = await this.conditionsParser.handleSafe(request);
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> {
await this.store.deleteResource(input.target);
await this.store.deleteResource(input.target, input.conditions);
return new ResetResponseDescription();
}
}

View File

@ -24,7 +24,7 @@ export class GetOperationHandler extends OperationHandler {
}
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);

View File

@ -24,7 +24,7 @@ export class HeadOperationHandler extends OperationHandler {
}
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.
body.data.destroy();

View File

@ -1,4 +1,5 @@
import type { Authorization } from '../../authorization/Authorization';
import type { Conditions } from '../../storage/Conditions';
import type { Representation } from '../representation/Representation';
import type { RepresentationPreferences } from '../representation/RepresentationPreferences';
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.
*/
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.
*/

View File

@ -36,7 +36,7 @@ export class PatchOperationHandler extends OperationHandler {
this.logger.warn('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();
}
}

View File

@ -35,7 +35,7 @@ export class PostOperationHandler extends OperationHandler {
this.logger.warn('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);
}
}

View File

@ -35,7 +35,7 @@ export class PutOperationHandler extends OperationHandler {
this.logger.warn('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();
}
}

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.
* @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.
* This function assumes the resource being checked exists.
* If not, the `matchesMetadata` function should be used.
* @param eTag - Condition based on ETag.
* @param lastModified - Condition based on last modified date.
*/

View File

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

View File

@ -1,5 +1,6 @@
import { BasicRequestParser } from '../../../../src/ldp/http/BasicRequestParser';
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 { PreferenceParser } from '../../../../src/ldp/http/PreferenceParser';
import type { TargetExtractor } from '../../../../src/ldp/http/TargetExtractor';
@ -10,6 +11,7 @@ describe('A BasicRequestParser', (): void => {
let targetExtractor: TargetExtractor;
let preferenceParser: PreferenceParser;
let metadataParser: MetadataParser;
let conditionsParser: ConditionsParser;
let bodyParser: BodyParser;
let requestParser: BasicRequestParser;
@ -17,8 +19,11 @@ describe('A BasicRequestParser', (): void => {
targetExtractor = new StaticAsyncHandler(true, 'target' as any);
preferenceParser = new StaticAsyncHandler(true, 'preference' as any);
metadataParser = new StaticAsyncHandler(true, undefined);
conditionsParser = new StaticAsyncHandler(true, 'conditions' 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> => {
@ -36,6 +41,7 @@ describe('A BasicRequestParser', (): void => {
method: 'GET',
target: 'target',
preferences: 'preference',
conditions: 'conditions',
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 type { Operation } from '../../../../src/ldp/operations/Operation';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A DeleteOperationHandler', (): void => {
const conditions = new BasicConditions({});
const store = {} as unknown as ResourceStore;
const handler = new DeleteOperationHandler(store);
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> => {
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).toHaveBeenLastCalledWith({ path: 'url' });
expect(store.deleteResource).toHaveBeenLastCalledWith({ path: 'url' }, conditions);
expect(result.statusCode).toBe(205);
expect(result.metadata).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 type { Operation } from '../../../../src/ldp/operations/Operation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A GetOperationHandler', (): void => {
const store = {
getRepresentation: async(): Promise<Representation> =>
({ binary: false, data: 'data', metadata: 'metadata' } as any),
const conditions = new BasicConditions({});
const preferences = {};
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;
const handler = new GetOperationHandler(store);
handler = new GetOperationHandler(store);
});
it('only supports GET operations.', async(): Promise<void> => {
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> => {
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.metadata).toBe('metadata');
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> => {
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(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions);
expect(authorization.addMetadata).toHaveBeenCalledTimes(1);
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 type { Operation } from '../../../../src/ldp/operations/Operation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A HeadOperationHandler', (): void => {
const conditions = new BasicConditions({});
const preferences = {};
let store: ResourceStore;
let handler: HeadOperationHandler;
let data: Readable;
@ -14,8 +17,8 @@ describe('A HeadOperationHandler', (): void => {
beforeEach(async(): Promise<void> => {
data = { destroy: jest.fn() } as any;
store = {
getRepresentation: async(): Promise<Representation> =>
({ binary: false, data, metadata: 'metadata' } as any),
getRepresentation: jest.fn(async(): Promise<Representation> =>
({ binary: false, data, metadata: 'metadata' } as any)),
} as any;
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> => {
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.metadata).toBe('metadata');
expect(result.data).toBeUndefined();
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> => {
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(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions);
expect(authorization.addMetadata).toHaveBeenCalledTimes(1);
expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata');
});

View File

@ -1,11 +1,13 @@
import type { Operation } from '../../../../src/ldp/operations/Operation';
import { PatchOperationHandler } from '../../../../src/ldp/operations/PatchOperationHandler';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A PatchOperationHandler', (): void => {
const conditions = new BasicConditions({});
const store = {} as unknown as ResourceStore;
const handler = new PatchOperationHandler(store);
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> => {
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).toHaveBeenLastCalledWith({ path: 'url' }, { metadata });
expect(store.modifyResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions);
expect(result.statusCode).toBe(205);
expect(result.metadata).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 { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { SOLID_HTTP } from '../../../../src/util/Vocabularies';
describe('A PostOperationHandler', (): void => {
const store = {
addResource: async(): Promise<ResourceIdentifier> => ({ path: 'newPath' } as ResourceIdentifier),
const conditions = new BasicConditions({});
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;
const handler = new PostOperationHandler(store);
handler = new PostOperationHandler(store);
});
it('only supports POST operations.', async(): Promise<void> => {
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> => {
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.metadata).toBeInstanceOf(RepresentationMetadata);
expect(result.metadata?.get(SOLID_HTTP.location)?.value).toBe('newPath');
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 { PutOperationHandler } from '../../../../src/ldp/operations/PutOperationHandler';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A PutOperationHandler', (): void => {
const conditions = new BasicConditions({});
const store = {} as unknown as ResourceStore;
const handler = new PutOperationHandler(store);
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> => {
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).toHaveBeenLastCalledWith({ path: 'url' }, { metadata });
expect(store.setRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions);
expect(result.statusCode).toBe(205);
expect(result.metadata).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();
});
});
});