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

@@ -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.
*/