mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Create conditions based on input headers
This commit is contained in:
parent
77d695c8b6
commit
20f783a581
@ -1,5 +1,6 @@
|
||||
[
|
||||
"Adapter",
|
||||
"BasicConditions",
|
||||
"BasicRepresentation",
|
||||
"Error",
|
||||
"EventEmitter",
|
||||
|
@ -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": [
|
||||
|
@ -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';
|
||||
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
63
src/ldp/http/conditions/BasicConditionsParser.ts
Normal file
63
src/ldp/http/conditions/BasicConditionsParser.ts
Normal 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);
|
||||
}
|
||||
}
|
8
src/ldp/http/conditions/ConditionsParser.ts
Normal file
8
src/ldp/http/conditions/ConditionsParser.ts
Normal 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> {}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
69
src/storage/BasicConditions.ts
Normal file
69
src/storage/BasicConditions.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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.
|
||||
*/
|
||||
|
@ -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,
|
||||
|
@ -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') },
|
||||
});
|
||||
});
|
||||
|
60
test/unit/ldp/http/conditions/BasicConditionsParser.test.ts
Normal file
60
test/unit/ldp/http/conditions/BasicConditionsParser.test.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
|
78
test/unit/storage/BasicConditions.test.ts
Normal file
78
test/unit/storage/BasicConditions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
17
test/unit/storage/Conditions.test.ts
Normal file
17
test/unit/storage/Conditions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user