refactor: Restructure source code folder

This way the location of certain classes should make more sense
This commit is contained in:
Joachim Van Herwegen
2021-10-08 10:58:35 +02:00
parent 012d9e0864
commit b3da9c9fcf
280 changed files with 684 additions and 673 deletions

35
src/http/Operation.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { PermissionSet } from '../authorization/permissions/Permissions';
import type { Conditions } from '../storage/Conditions';
import type { Representation } from './representation/Representation';
import type { RepresentationPreferences } from './representation/RepresentationPreferences';
import type { ResourceIdentifier } from './representation/ResourceIdentifier';
/**
* A single REST operation.
*/
export interface Operation {
/**
* The HTTP method (GET/POST/PUT/PATCH/DELETE/etc.).
*/
method: string;
/**
* Identifier of the target.
*/
target: ResourceIdentifier;
/**
* 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;
/**
* The permissions available for the current operation.
*/
permissionSet?: PermissionSet;
/**
* Optional representation of the body.
*/
body?: Representation;
}

View File

@@ -0,0 +1,146 @@
import { EventEmitter } from 'events';
import type WebSocket from 'ws';
import { getLoggerFor } from '../logging/LogUtil';
import type { HttpRequest } from '../server/HttpRequest';
import { WebSocketHandler } from '../server/WebSocketHandler';
import { parseForwarded } from '../util/HeaderUtil';
import type { ResourceIdentifier } from './representation/ResourceIdentifier';
const VERSION = 'solid-0.1';
/**
* Implementation of Solid WebSockets API Spec solid-0.1
* at https://github.com/solid/solid-spec/blob/master/api-websockets.md
*/
class WebSocketListener extends EventEmitter {
private host = '';
private protocol = '';
private readonly socket: WebSocket;
private readonly subscribedPaths = new Set<string>();
private readonly logger = getLoggerFor(this);
public constructor(socket: WebSocket) {
super();
this.socket = socket;
socket.addListener('error', (): void => this.stop());
socket.addListener('close', (): void => this.stop());
socket.addListener('message', (message: string): void => this.onMessage(message));
}
public start({ headers, socket }: HttpRequest): void {
// Greet the client
this.sendMessage('protocol', VERSION);
// Verify the WebSocket protocol version
const protocolHeader = headers['sec-websocket-protocol'];
if (!protocolHeader) {
this.sendMessage('warning', `Missing Sec-WebSocket-Protocol header, expected value '${VERSION}'`);
} else {
const supportedProtocols = protocolHeader.split(/\s*,\s*/u);
if (!supportedProtocols.includes(VERSION)) {
this.sendMessage('error', `Client does not support protocol ${VERSION}`);
this.stop();
}
}
// Store the HTTP host and protocol
const forwarded = parseForwarded(headers);
this.host = forwarded.host ?? headers.host ?? 'localhost';
this.protocol = forwarded.proto === 'https' || (socket as any).secure ? 'https:' : 'http:';
}
private stop(): void {
try {
this.socket.close();
} catch {
// Ignore
}
this.subscribedPaths.clear();
this.socket.removeAllListeners();
this.emit('closed');
}
public onResourceChanged({ path }: ResourceIdentifier): void {
if (this.subscribedPaths.has(path)) {
this.sendMessage('pub', path);
}
}
private onMessage(message: string): void {
// Parse the message
const match = /^(\w+)\s+(.+)$/u.exec(message);
if (!match) {
this.sendMessage('warning', `Unrecognized message format: ${message}`);
return;
}
// Process the message
const [ , type, value ] = match;
switch (type) {
case 'sub':
this.subscribe(value);
break;
default:
this.sendMessage('warning', `Unrecognized message type: ${type}`);
}
}
private subscribe(path: string): void {
try {
// Resolve and verify the URL
const resolved = new URL(path, `${this.protocol}${this.host}`);
if (resolved.host !== this.host) {
throw new Error(`Mismatched host: ${resolved.host} instead of ${this.host}`);
}
if (resolved.protocol !== this.protocol) {
throw new Error(`Mismatched protocol: ${resolved.protocol} instead of ${this.protocol}`);
}
// Subscribe to the URL
const url = resolved.href;
this.subscribedPaths.add(url);
this.sendMessage('ack', url);
this.logger.debug(`WebSocket subscribed to changes on ${url}`);
} catch (error: unknown) {
// Report errors to the socket
const errorText: string = (error as any).message;
this.sendMessage('error', errorText);
this.logger.warn(`WebSocket could not subscribe to ${path}: ${errorText}`);
}
}
private sendMessage(type: string, value: string): void {
this.socket.send(`${type} ${value}`);
}
}
/**
* Provides live update functionality following
* the Solid WebSockets API Spec solid-0.1
*/
export class UnsecureWebSocketsProtocol extends WebSocketHandler {
private readonly logger = getLoggerFor(this);
private readonly listeners = new Set<WebSocketListener>();
public constructor(source: EventEmitter) {
super();
source.on('changed', (changed: ResourceIdentifier): void => this.onResourceChanged(changed));
}
public async handle(input: { webSocket: WebSocket; upgradeRequest: HttpRequest }): Promise<void> {
const listener = new WebSocketListener(input.webSocket);
this.listeners.add(listener);
this.logger.info(`New WebSocket added, ${this.listeners.size} in total`);
listener.on('closed', (): void => {
this.listeners.delete(listener);
this.logger.info(`WebSocket closed, ${this.listeners.size} remaining`);
});
listener.start(input.upgradeRequest);
}
private onResourceChanged(changed: ResourceIdentifier): void {
for (const listener of this.listeners) {
listener.onResourceChanged(changed);
}
}
}

View File

@@ -0,0 +1,48 @@
import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
/**
* A strategy for handling auxiliary related ResourceIdentifiers.
*/
export interface AuxiliaryIdentifierStrategy {
/**
* Returns the identifier of the auxiliary resource corresponding to the given resource.
* This does not guarantee that this auxiliary resource exists.
*
* Should error if there are multiple results: see {@link getAuxiliaryIdentifiers}.
*
* @param identifier - The ResourceIdentifier of which we need the corresponding auxiliary resource.
*
* @returns The ResourceIdentifier of the corresponding auxiliary resource.
*/
getAuxiliaryIdentifier: (identifier: ResourceIdentifier) => ResourceIdentifier;
/**
* Returns all the identifiers of corresponding auxiliary resources.
* This can be used when there are potentially multiple results.
* In the case of a single result this should be an array containing the result of {@link getAuxiliaryIdentifier}.
*
* @param identifier - The ResourceIdentifier of which we need the corresponding auxiliary resources.
*
* @returns The ResourceIdentifiers of the corresponding auxiliary resources.
*/
getAuxiliaryIdentifiers: (identifier: ResourceIdentifier) => ResourceIdentifier[];
/**
* Checks if the input identifier corresponds to an auxiliary resource.
* This does not check if that auxiliary resource exists,
* only if the identifier indicates that there could be an auxiliary resource there.
* @param identifier - Identifier to check.
*
* @returns true if the input identifier points to an auxiliary resource.
*/
isAuxiliaryIdentifier: (identifier: ResourceIdentifier) => boolean;
/**
* Returns the identifier of the resource which this auxiliary resource is referring to.
* This does not guarantee that this resource exists.
* @param identifier - Identifier of the auxiliary resource.
*
* @returns The ResourceIdentifier of the subject resource.
*/
getSubjectIdentifier: (identifier: ResourceIdentifier) => ResourceIdentifier;
}

View File

@@ -0,0 +1,49 @@
import type { Representation } from '../representation/Representation';
import type { RepresentationMetadata } from '../representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
import type { AuxiliaryIdentifierStrategy } from './AuxiliaryIdentifierStrategy';
/**
* A strategy for handling one or more types of auxiliary resources.
* References to "an auxiliary resource" implicitly imply a specific type of auxiliary resources
* supported by this strategy.
*/
export interface AuxiliaryStrategy extends AuxiliaryIdentifierStrategy {
/**
* Whether this auxiliary resources uses its own authorization instead of the subject resource authorization.
* @param identifier - Identifier of the auxiliary resource.
*/
usesOwnAuthorization: (identifier: ResourceIdentifier) => boolean;
/**
* Whether the root storage container requires this auxiliary resource to be present.
* If yes, this means they can't be deleted individually from such a container.
* @param identifier - Identifier of the auxiliary resource.
*/
isRequiredInRoot: (identifier: ResourceIdentifier) => boolean;
/**
* Adds metadata related to this auxiliary resource,
* in case this is required for this type of auxiliary resource.
* The metadata that is added depends on the given identifier being an auxiliary or subject resource:
* the metadata will be used to link to the other one, and potentially add extra typing info.
*
* Used for:
* Solid, §4.3.1: "For any defined auxiliary resource available for a given Solid resource, all representations of
* that resource MUST include an HTTP Link header pointing to the location of each auxiliary resource."
* https://solid.github.io/specification/protocol#auxiliary-resources-server
*
* The above is an example of how that metadata would only be added in case the input is the subject identifier.
*
* @param metadata - Metadata to update.
*/
addMetadata: (metadata: RepresentationMetadata) => Promise<void>;
/**
* Validates if the representation contains valid data for an auxiliary resource.
* Should throw an error in case the data is invalid.
* @param identifier - Identifier of the auxiliary resource.
* @param representation - Representation of the auxiliary resource.
*/
validate: (representation: Representation) => Promise<void>;
}

View File

@@ -0,0 +1,64 @@
import type { Representation } from '../representation/Representation';
import type { RepresentationMetadata } from '../representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
import type { AuxiliaryIdentifierStrategy } from './AuxiliaryIdentifierStrategy';
import type { AuxiliaryStrategy } from './AuxiliaryStrategy';
import type { MetadataGenerator } from './MetadataGenerator';
import type { Validator } from './Validator';
/**
* An {@link AuxiliaryStrategy} that provides its functionality through the combination of
* an {@link AuxiliaryIdentifierStrategy}, {@link MetadataGenerator} and {@link Validator}.
*/
export class ComposedAuxiliaryStrategy implements AuxiliaryStrategy {
private readonly identifierStrategy: AuxiliaryIdentifierStrategy;
private readonly metadataGenerator?: MetadataGenerator;
private readonly validator?: Validator;
private readonly ownAuthorization: boolean;
private readonly requiredInRoot: boolean;
public constructor(identifierStrategy: AuxiliaryIdentifierStrategy, metadataGenerator?: MetadataGenerator,
validator?: Validator, ownAuthorization = false, requiredInRoot = false) {
this.identifierStrategy = identifierStrategy;
this.metadataGenerator = metadataGenerator;
this.validator = validator;
this.ownAuthorization = ownAuthorization;
this.requiredInRoot = requiredInRoot;
}
public getAuxiliaryIdentifier(identifier: ResourceIdentifier): ResourceIdentifier {
return this.identifierStrategy.getAuxiliaryIdentifier(identifier);
}
public getAuxiliaryIdentifiers(identifier: ResourceIdentifier): ResourceIdentifier[] {
return this.identifierStrategy.getAuxiliaryIdentifiers(identifier);
}
public isAuxiliaryIdentifier(identifier: ResourceIdentifier): boolean {
return this.identifierStrategy.isAuxiliaryIdentifier(identifier);
}
public getSubjectIdentifier(identifier: ResourceIdentifier): ResourceIdentifier {
return this.identifierStrategy.getSubjectIdentifier(identifier);
}
public usesOwnAuthorization(): boolean {
return this.ownAuthorization;
}
public isRequiredInRoot(): boolean {
return this.requiredInRoot;
}
public async addMetadata(metadata: RepresentationMetadata): Promise<void> {
if (this.metadataGenerator) {
return this.metadataGenerator.handleSafe(metadata);
}
}
public async validate(representation: Representation): Promise<void> {
if (this.validator) {
return this.validator.handleSafe(representation);
}
}
}

View File

@@ -0,0 +1,31 @@
import { namedNode } from '@rdfjs/data-model';
import { SOLID_META } from '../../util/Vocabularies';
import type { RepresentationMetadata } from '../representation/RepresentationMetadata';
import type { AuxiliaryIdentifierStrategy } from './AuxiliaryIdentifierStrategy';
import { MetadataGenerator } from './MetadataGenerator';
/**
* Adds a link to the auxiliary resource when called on the subject resource.
* Specifically: <subjectId> <link> <auxiliaryId> will be added.
*
* In case the input is metadata of an auxiliary resource no metadata will be added
*/
export class LinkMetadataGenerator extends MetadataGenerator {
private readonly link: string;
private readonly identifierStrategy: AuxiliaryIdentifierStrategy;
public constructor(link: string, identifierStrategy: AuxiliaryIdentifierStrategy) {
super();
this.link = link;
this.identifierStrategy = identifierStrategy;
}
public async handle(metadata: RepresentationMetadata): Promise<void> {
const identifier = { path: metadata.identifier.value };
if (!this.identifierStrategy.isAuxiliaryIdentifier(identifier)) {
metadata.add(this.link,
namedNode(this.identifierStrategy.getAuxiliaryIdentifier(identifier).path),
SOLID_META.terms.ResponseMetadata);
}
}
}

View File

@@ -0,0 +1,7 @@
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { RepresentationMetadata } from '../representation/RepresentationMetadata';
/**
* Generic interface for classes that add metadata to a RepresentationMetadata.
*/
export abstract class MetadataGenerator extends AsyncHandler<RepresentationMetadata> { }

View File

@@ -0,0 +1,43 @@
import arrayifyStream from 'arrayify-stream';
import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { cloneRepresentation } from '../../util/ResourceUtil';
import type { Representation } from '../representation/Representation';
import { Validator } from './Validator';
/**
* Validates a Representation by verifying if the data stream contains valid RDF data.
* It does this by letting the stored RepresentationConverter convert the data.
*/
export class RdfValidator extends Validator {
protected readonly converter: RepresentationConverter;
public constructor(converter: RepresentationConverter) {
super();
this.converter = converter;
}
public async handle(representation: Representation): Promise<void> {
// If the data already is quads format we know it's RDF
if (representation.metadata.contentType === INTERNAL_QUADS) {
return;
}
const identifier = { path: representation.metadata.identifier.value };
const preferences = { type: { [INTERNAL_QUADS]: 1 }};
let result;
try {
// Creating new representation since converter might edit metadata
const tempRepresentation = await cloneRepresentation(representation);
result = await this.converter.handleSafe({
identifier,
representation: tempRepresentation,
preferences,
});
} catch (error: unknown) {
representation.data.destroy();
throw error;
}
// Drain stream to make sure data was parsed correctly
await arrayifyStream(result.data);
}
}

View File

@@ -0,0 +1,44 @@
import { InternalServerError } from '../../util/errors/InternalServerError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
import type { AuxiliaryIdentifierStrategy } from './AuxiliaryIdentifierStrategy';
/**
* An {@link AuxiliaryIdentifierStrategy} that combines multiple AuxiliaryIdentifierStrategies into one.
* Uses `isAuxiliaryIdentifier` to know which strategy to route to.
*/
export class RoutingAuxiliaryIdentifierStrategy implements AuxiliaryIdentifierStrategy {
protected readonly sources: AuxiliaryIdentifierStrategy[];
public constructor(sources: AuxiliaryIdentifierStrategy[]) {
this.sources = sources;
}
public getAuxiliaryIdentifier(): never {
throw new InternalServerError(
'RoutingAuxiliaryIdentifierStrategy has multiple auxiliary strategies and thus no single auxiliary identifier.',
);
}
public getAuxiliaryIdentifiers(identifier: ResourceIdentifier): ResourceIdentifier[] {
return this.sources.flatMap((source): ResourceIdentifier[] => source.getAuxiliaryIdentifiers(identifier));
}
public isAuxiliaryIdentifier(identifier: ResourceIdentifier): boolean {
return this.sources.some((source): boolean => source.isAuxiliaryIdentifier(identifier));
}
public getSubjectIdentifier(identifier: ResourceIdentifier): ResourceIdentifier {
const source = this.getMatchingSource(identifier);
return source.getSubjectIdentifier(identifier);
}
protected getMatchingSource(identifier: ResourceIdentifier): AuxiliaryIdentifierStrategy {
const match = this.sources.find((source): boolean => source.isAuxiliaryIdentifier(identifier));
if (!match) {
throw new NotImplementedHttpError(`Could not find an AuxiliaryManager for ${identifier.path}`);
}
return match;
}
}

View File

@@ -0,0 +1,54 @@
import type { Representation } from '../representation/Representation';
import type { RepresentationMetadata } from '../representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
import type { AuxiliaryStrategy } from './AuxiliaryStrategy';
import { RoutingAuxiliaryIdentifierStrategy } from './RoutingAuxiliaryIdentifierStrategy';
/**
* An {@link AuxiliaryStrategy} that combines multiple AuxiliaryStrategies into one.
* Uses `isAuxiliaryIdentifier` to know which strategy to call for which call.
*
* `addMetadata` will either call all strategies if the input is the subject identifier,
* or only the matching strategy if the input is an auxiliary identifier.
*/
export class RoutingAuxiliaryStrategy extends RoutingAuxiliaryIdentifierStrategy implements AuxiliaryStrategy {
protected readonly sources!: AuxiliaryStrategy[];
public constructor(sources: AuxiliaryStrategy[]) {
super(sources);
}
public usesOwnAuthorization(identifier: ResourceIdentifier): boolean {
const source = this.getMatchingSource(identifier);
return source.usesOwnAuthorization(identifier);
}
public isRequiredInRoot(identifier: ResourceIdentifier): boolean {
const source = this.getMatchingSource(identifier);
return source.isRequiredInRoot(identifier);
}
public async addMetadata(metadata: RepresentationMetadata): Promise<void> {
const identifier = { path: metadata.identifier.value };
// Make sure unrelated auxiliary strategies don't add metadata to another auxiliary resource
const match = this.sources.find((source): boolean => source.isAuxiliaryIdentifier(identifier));
if (match) {
await match.addMetadata(metadata);
} else {
for (const source of this.sources) {
await source.addMetadata(metadata);
}
}
}
public async validate(representation: Representation): Promise<void> {
const identifier = { path: representation.metadata.identifier.value };
const source = this.getMatchingSource(identifier);
return source.validate(representation);
}
// Updated with new source typings
protected getMatchingSource(identifier: ResourceIdentifier): AuxiliaryStrategy {
return super.getMatchingSource(identifier) as AuxiliaryStrategy;
}
}

View File

@@ -0,0 +1,37 @@
import { InternalServerError } from '../../util/errors/InternalServerError';
import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
import type { AuxiliaryIdentifierStrategy } from './AuxiliaryIdentifierStrategy';
/**
* Helper class that uses a suffix to determine if a resource is an auxiliary resource or not.
* Simple string matching is used, so the dot needs to be included if needed, e.g. ".acl".
*/
export class SuffixAuxiliaryIdentifierStrategy implements AuxiliaryIdentifierStrategy {
protected readonly suffix: string;
public constructor(suffix: string) {
if (suffix.length === 0) {
throw new InternalServerError('Suffix length should be non-zero.');
}
this.suffix = suffix;
}
public getAuxiliaryIdentifier(identifier: ResourceIdentifier): ResourceIdentifier {
return { path: `${identifier.path}${this.suffix}` };
}
public getAuxiliaryIdentifiers(identifier: ResourceIdentifier): ResourceIdentifier[] {
return [ this.getAuxiliaryIdentifier(identifier) ];
}
public isAuxiliaryIdentifier(identifier: ResourceIdentifier): boolean {
return identifier.path.endsWith(this.suffix);
}
public getSubjectIdentifier(identifier: ResourceIdentifier): ResourceIdentifier {
if (!this.isAuxiliaryIdentifier(identifier)) {
throw new InternalServerError(`${identifier.path} does not end on ${this.suffix} so no conversion is possible.`);
}
return { path: identifier.path.slice(0, -this.suffix.length) };
}
}

View File

@@ -0,0 +1,7 @@
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { Representation } from '../representation/Representation';
/**
* Generic interface for classes that validate Representations in some way.
*/
export abstract class Validator extends AsyncHandler<Representation> { }

View File

@@ -0,0 +1,54 @@
import type { HttpRequest } from '../../server/HttpRequest';
import { InternalServerError } from '../../util/errors/InternalServerError';
import type { Operation } from '../Operation';
import { RepresentationMetadata } from '../representation/RepresentationMetadata';
import type { BodyParser } from './body/BodyParser';
import type { ConditionsParser } from './conditions/ConditionsParser';
import type { TargetExtractor } from './identifier/TargetExtractor';
import type { MetadataParser } from './metadata/MetadataParser';
import type { PreferenceParser } from './preferences/PreferenceParser';
import { RequestParser } from './RequestParser';
/**
* Input parsers required for a {@link BasicRequestParser}.
*/
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},
* {@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) {
super();
Object.assign(this, args);
}
public async handle(request: HttpRequest): Promise<Operation> {
const { method } = request;
if (!method) {
throw new InternalServerError('No method specified on the HTTP request');
}
const target = await this.targetExtractor.handleSafe({ request });
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, conditions, body };
}
}

View File

@@ -0,0 +1,8 @@
import type { HttpRequest } from '../../server/HttpRequest';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { Operation } from '../Operation';
/**
* Converts an incoming HttpRequest to an Operation.
*/
export abstract class RequestParser extends AsyncHandler<HttpRequest, Operation> {}

View File

@@ -0,0 +1,22 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { Representation } from '../../representation/Representation';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
export interface BodyParserArgs {
/**
* Request that contains the (potential) body.
*/
request: HttpRequest;
/**
* Metadata that has already been parsed from the request.
* Can be updated by the BodyParser with extra metadata.
*/
metadata: RepresentationMetadata;
}
/**
* Parses the body of an incoming {@link HttpRequest} and converts it to a {@link Representation}.
*/
export abstract class BodyParser extends
AsyncHandler<BodyParserArgs, Representation | undefined> {}

View File

@@ -0,0 +1,41 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { BasicRepresentation } from '../../representation/BasicRepresentation';
import type { Representation } from '../../representation/Representation';
import type { BodyParserArgs } from './BodyParser';
import { BodyParser } from './BodyParser';
/**
* Converts incoming {@link HttpRequest} to a Representation without any further parsing.
*/
export class RawBodyParser extends BodyParser {
protected readonly logger = getLoggerFor(this);
// Note that the only reason this is a union is in case the body is empty.
// If this check gets moved away from the BodyParsers this union could be removed
public async handle({ request, metadata }: BodyParserArgs): Promise<Representation | undefined> {
const {
'content-type': contentType,
'content-length': contentLength,
'transfer-encoding': transferEncoding,
} = request.headers;
// RFC7230, §3.3: The presence of a message body in a request
// is signaled by a Content-Length or Transfer-Encoding header field.
// While clients SHOULD NOT use use a Content-Length header on GET,
// some still provide a Content-Length of 0 (but without Content-Type).
if ((!contentLength || (/^0+$/u.test(contentLength) && !contentType)) && !transferEncoding) {
this.logger.debug('HTTP request does not have a body, or its empty body is missing a Content-Type header');
return;
}
// While RFC7231 allows treating a body without content type as an octet stream,
// such an omission likely signals a mistake, so force clients to make this explicit.
if (!contentType) {
this.logger.warn('HTTP request has a body, but no Content-Type header');
throw new BadRequestHttpError('HTTP request body was passed without a Content-Type header');
}
return new BasicRepresentation(request, metadata);
}
}

View File

@@ -0,0 +1,43 @@
import type { Algebra } from 'sparqlalgebrajs';
import { translate } from 'sparqlalgebrajs';
import { getLoggerFor } from '../../../logging/LogUtil';
import { APPLICATION_SPARQL_UPDATE } from '../../../util/ContentTypes';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError';
import { guardedStreamFrom, readableToString } from '../../../util/StreamUtil';
import type { SparqlUpdatePatch } from '../../representation/SparqlUpdatePatch';
import type { BodyParserArgs } from './BodyParser';
import { BodyParser } from './BodyParser';
/**
* {@link BodyParser} that supports `application/sparql-update` content.
* Will convert the incoming update string to algebra in a {@link SparqlUpdatePatch}.
*/
export class SparqlUpdateBodyParser extends BodyParser {
protected readonly logger = getLoggerFor(this);
public async canHandle({ metadata }: BodyParserArgs): Promise<void> {
if (metadata.contentType !== APPLICATION_SPARQL_UPDATE) {
throw new UnsupportedMediaTypeHttpError('This parser only supports SPARQL UPDATE data.');
}
}
public async handle({ request, metadata }: BodyParserArgs): Promise<SparqlUpdatePatch> {
const sparql = await readableToString(request);
let algebra: Algebra.Operation;
try {
algebra = translate(sparql, { quads: true, baseIRI: metadata.identifier.value });
} catch (error: unknown) {
this.logger.warn('Could not translate SPARQL query to SPARQL algebra', { error });
throw new BadRequestHttpError(createErrorMessage(error), { cause: error });
}
// Prevent body from being requested again
return {
algebra,
binary: true,
data: guardedStreamFrom(sparql),
metadata,
};
}
}

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

@@ -0,0 +1,58 @@
import type { TLSSocket } from 'tls';
import type { HttpRequest } from '../../../server/HttpRequest';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { InternalServerError } from '../../../util/errors/InternalServerError';
import { parseForwarded } from '../../../util/HeaderUtil';
import { toCanonicalUriPath } from '../../../util/PathUtil';
import type { ResourceIdentifier } from '../../representation/ResourceIdentifier';
import { TargetExtractor } from './TargetExtractor';
/**
* Reconstructs the original URL of an incoming {@link HttpRequest}.
*/
export class OriginalUrlExtractor extends TargetExtractor {
private readonly includeQueryString: boolean;
public constructor(options: { includeQueryString?: boolean } = {}) {
super();
this.includeQueryString = options.includeQueryString ?? true;
}
public async handle({ request: { url, connection, headers }}: { request: HttpRequest }): Promise<ResourceIdentifier> {
if (!url) {
throw new InternalServerError('Missing URL');
}
// Extract host and protocol (possibly overridden by the Forwarded/X-Forwarded-* header)
let { host } = headers;
let protocol = (connection as TLSSocket)?.encrypted ? 'https' : 'http';
// Check Forwarded/X-Forwarded-* headers
const forwarded = parseForwarded(headers);
if (forwarded.host) {
({ host } = forwarded);
}
if (forwarded.proto) {
({ proto: protocol } = forwarded);
}
// Perform a sanity check on the host
if (!host) {
throw new BadRequestHttpError('Missing Host header');
}
if (/[/\\*]/u.test(host)) {
throw new BadRequestHttpError(`The request has an invalid Host header: ${host}`);
}
// URL object applies punycode encoding to domain
const base = `${protocol}://${host}`;
const originalUrl = new URL(toCanonicalUriPath(url), base);
// Drop the query string if requested
if (!this.includeQueryString) {
originalUrl.search = '';
}
return { path: originalUrl.href };
}
}

View File

@@ -0,0 +1,8 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { ResourceIdentifier } from '../../representation/ResourceIdentifier';
/**
* Extracts a {@link ResourceIdentifier} from an incoming {@link HttpRequest}.
*/
export abstract class TargetExtractor extends AsyncHandler<{ request: HttpRequest }, ResourceIdentifier> {}

View File

@@ -0,0 +1,17 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataParser } from './MetadataParser';
/**
* Parser for the `content-type` header.
* Currently only stores the media type and ignores other parameters such as charset.
*/
export class ContentTypeParser extends MetadataParser {
public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise<void> {
const contentType = input.request.headers['content-type'];
if (contentType) {
// Will need to use HeaderUtil once parameters need to be parsed
input.metadata.contentType = /^[^;]*/u.exec(contentType)![0].trim();
}
}
}

View File

@@ -0,0 +1,48 @@
import type { NamedNode } from '@rdfjs/types';
import { DataFactory } from 'n3';
import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpRequest } from '../../../server/HttpRequest';
import { parseParameters, splitAndClean, transformQuotedStrings } from '../../../util/HeaderUtil';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataParser } from './MetadataParser';
import namedNode = DataFactory.namedNode;
/**
* Parses Link headers with a specific `rel` value and adds them as metadata with the given predicate.
*/
export class LinkRelParser extends MetadataParser {
protected readonly logger = getLoggerFor(this);
private readonly linkRelMap: Record<string, NamedNode>;
public constructor(linkRelMap: Record<string, string>) {
super();
this.linkRelMap = Object.fromEntries(
Object.entries(linkRelMap).map(([ header, uri ]): [string, NamedNode] => [ header, namedNode(uri) ]),
);
}
public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise<void> {
const link = input.request.headers.link ?? [];
const entries: string[] = Array.isArray(link) ? link : [ link ];
for (const entry of entries) {
this.parseLink(entry, input.metadata);
}
}
protected parseLink(linkEntry: string, metadata: RepresentationMetadata): void {
const { result, replacements } = transformQuotedStrings(linkEntry);
for (const part of splitAndClean(result)) {
const [ link, ...parameters ] = part.split(/\s*;\s*/u);
if (/^[^<]|[^>]$/u.test(link)) {
this.logger.warn(`Invalid link header ${part}.`);
continue;
}
for (const { name, value } of parseParameters(parameters, replacements)) {
if (name === 'rel' && this.linkRelMap[value]) {
metadata.add(this.linkRelMap[value], namedNode(link.slice(1, -1)));
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
/**
* A parser that takes a specific part of an HttpRequest and converts it into metadata,
* such as the value of a header entry.
*/
export abstract class MetadataParser extends AsyncHandler<{ request: HttpRequest; metadata: RepresentationMetadata }> {}

View File

@@ -0,0 +1,25 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpRequest } from '../../../server/HttpRequest';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { SOLID_HTTP } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataParser } from './MetadataParser';
/**
* Converts the contents of the slug header to metadata.
*/
export class SlugParser extends MetadataParser {
protected readonly logger = getLoggerFor(this);
public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise<void> {
const { slug } = input.request.headers;
if (slug) {
if (Array.isArray(slug)) {
this.logger.warn(`Expected 0 or 1 Slug headers but received ${slug.length}`);
throw new BadRequestHttpError('Request has multiple Slug headers');
}
this.logger.debug(`Request Slug is '${slug}'.`);
input.metadata.set(SOLID_HTTP.slug, slug);
}
}
}

View File

@@ -0,0 +1,45 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import type { AcceptHeader } from '../../../util/HeaderUtil';
import {
parseAccept,
parseAcceptCharset,
parseAcceptEncoding,
parseAcceptLanguage,
parseAcceptDateTime,
} from '../../../util/HeaderUtil';
import type { RepresentationPreferences } from '../../representation/RepresentationPreferences';
import { PreferenceParser } from './PreferenceParser';
const parsers: {
name: keyof RepresentationPreferences;
header: string;
parse: (value: string) => AcceptHeader[];
}[] = [
{ name: 'type', header: 'accept', parse: parseAccept },
{ name: 'charset', header: 'accept-charset', parse: parseAcceptCharset },
{ name: 'encoding', header: 'accept-encoding', parse: parseAcceptEncoding },
{ name: 'language', header: 'accept-language', parse: parseAcceptLanguage },
{ name: 'datetime', header: 'accept-datetime', parse: parseAcceptDateTime },
];
/**
* Extracts preferences from the Accept-* headers from an incoming {@link HttpRequest}.
* Supports Accept, Accept-Charset, Accept-Encoding, Accept-Language and Accept-DateTime.
*/
export class AcceptPreferenceParser extends PreferenceParser {
public async handle({ request: { headers }}: { request: HttpRequest }): Promise<RepresentationPreferences> {
const preferences: RepresentationPreferences = {};
for (const { name, header, parse } of parsers) {
const value = headers[header];
if (typeof value === 'string') {
const result = Object.fromEntries(parse(value)
.map(({ range, weight }): [string, number] => [ range, weight ]));
// Interpret empty headers (or headers with no valid values) the same as missing headers
if (Object.keys(result).length > 0) {
preferences[name] = result;
}
}
}
return preferences;
}
}

View File

@@ -0,0 +1,8 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { RepresentationPreferences } from '../../representation/RepresentationPreferences';
/**
* Creates {@link RepresentationPreferences} based on the incoming HTTP headers in a {@link HttpRequest}.
*/
export abstract class PreferenceParser extends AsyncHandler<{ request: HttpRequest }, RepresentationPreferences> {}

View File

@@ -0,0 +1,30 @@
import type { ResourceStore } from '../../storage/ResourceStore';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { ResetResponseDescription } from '../output/response/ResetResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import type { OperationHandlerInput } from './OperationHandler';
import { OperationHandler } from './OperationHandler';
/**
* Handles DELETE {@link Operation}s.
* Calls the deleteResource function from a {@link ResourceStore}.
*/
export class DeleteOperationHandler extends OperationHandler {
private readonly store: ResourceStore;
public constructor(store: ResourceStore) {
super();
this.store = store;
}
public async canHandle({ operation }: OperationHandlerInput): Promise<void> {
if (operation.method !== 'DELETE') {
throw new NotImplementedHttpError('This handler only supports DELETE operations');
}
}
public async handle({ operation }: OperationHandlerInput): Promise<ResponseDescription> {
await this.store.deleteResource(operation.target, operation.conditions);
return new ResetResponseDescription();
}
}

View File

@@ -0,0 +1,31 @@
import type { ResourceStore } from '../../storage/ResourceStore';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { OkResponseDescription } from '../output/response/OkResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import type { OperationHandlerInput } from './OperationHandler';
import { OperationHandler } from './OperationHandler';
/**
* Handles GET {@link Operation}s.
* Calls the getRepresentation function from a {@link ResourceStore}.
*/
export class GetOperationHandler extends OperationHandler {
private readonly store: ResourceStore;
public constructor(store: ResourceStore) {
super();
this.store = store;
}
public async canHandle({ operation }: OperationHandlerInput): Promise<void> {
if (operation.method !== 'GET') {
throw new NotImplementedHttpError('This handler only supports GET operations');
}
}
public async handle({ operation }: OperationHandlerInput): Promise<ResponseDescription> {
const body = await this.store.getRepresentation(operation.target, operation.preferences, operation.conditions);
return new OkResponseDescription(body.metadata, body.data);
}
}

View File

@@ -0,0 +1,34 @@
import type { ResourceStore } from '../../storage/ResourceStore';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { OkResponseDescription } from '../output/response/OkResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import type { OperationHandlerInput } from './OperationHandler';
import { OperationHandler } from './OperationHandler';
/**
* Handles HEAD {@link Operation}s.
* Calls the getRepresentation function from a {@link ResourceStore}.
*/
export class HeadOperationHandler extends OperationHandler {
private readonly store: ResourceStore;
public constructor(store: ResourceStore) {
super();
this.store = store;
}
public async canHandle({ operation }: OperationHandlerInput): Promise<void> {
if (operation.method !== 'HEAD') {
throw new NotImplementedHttpError('This handler only supports HEAD operations');
}
}
public async handle({ operation }: OperationHandlerInput): Promise<ResponseDescription> {
const body = await this.store.getRepresentation(operation.target, operation.preferences, operation.conditions);
// Close the Readable as we will not return it.
body.data.destroy();
return new OkResponseDescription(body.metadata);
}
}

View File

@@ -0,0 +1,12 @@
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { Operation } from '../Operation';
import type { ResponseDescription } from '../output/response/ResponseDescription';
export interface OperationHandlerInput {
operation: Operation;
}
/**
* Handler for a specific operation type.
*/
export abstract class OperationHandler extends AsyncHandler<OperationHandlerInput, ResponseDescription> {}

View File

@@ -0,0 +1,42 @@
import { getLoggerFor } from '../../logging/LogUtil';
import type { ResourceStore } from '../../storage/ResourceStore';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { ResetResponseDescription } from '../output/response/ResetResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import type { Patch } from '../representation/Patch';
import type { OperationHandlerInput } from './OperationHandler';
import { OperationHandler } from './OperationHandler';
/**
* Handles PATCH {@link Operation}s.
* Calls the modifyResource function from a {@link ResourceStore}.
*/
export class PatchOperationHandler extends OperationHandler {
protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore;
public constructor(store: ResourceStore) {
super();
this.store = store;
}
public async canHandle({ operation }: OperationHandlerInput): Promise<void> {
if (operation.method !== 'PATCH') {
throw new NotImplementedHttpError('This handler only supports PATCH operations.');
}
}
public async handle({ operation }: OperationHandlerInput): Promise<ResponseDescription> {
// Solid, §2.1: "A Solid server MUST reject PUT, POST and PATCH requests
// without the Content-Type header with a status code of 400."
// https://solid.github.io/specification/protocol#http-server
if (!operation.body?.metadata.contentType) {
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(operation.target, operation.body as Patch, operation.conditions);
return new ResetResponseDescription();
}
}

View File

@@ -0,0 +1,41 @@
import { getLoggerFor } from '../../logging/LogUtil';
import type { ResourceStore } from '../../storage/ResourceStore';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { CreatedResponseDescription } from '../output/response/CreatedResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import type { OperationHandlerInput } from './OperationHandler';
import { OperationHandler } from './OperationHandler';
/**
* Handles POST {@link Operation}s.
* Calls the addResource function from a {@link ResourceStore}.
*/
export class PostOperationHandler extends OperationHandler {
protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore;
public constructor(store: ResourceStore) {
super();
this.store = store;
}
public async canHandle({ operation }: OperationHandlerInput): Promise<void> {
if (operation.method !== 'POST') {
throw new NotImplementedHttpError('This handler only supports POST operations');
}
}
public async handle({ operation }: OperationHandlerInput): Promise<ResponseDescription> {
// Solid, §2.1: "A Solid server MUST reject PUT, POST and PATCH requests
// without the Content-Type header with a status code of 400."
// https://solid.github.io/specification/protocol#http-server
if (!operation.body?.metadata.contentType) {
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(operation.target, operation.body, operation.conditions);
return new CreatedResponseDescription(identifier);
}
}

View File

@@ -0,0 +1,41 @@
import { getLoggerFor } from '../../logging/LogUtil';
import type { ResourceStore } from '../../storage/ResourceStore';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { ResetResponseDescription } from '../output/response/ResetResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import type { OperationHandlerInput } from './OperationHandler';
import { OperationHandler } from './OperationHandler';
/**
* Handles PUT {@link Operation}s.
* Calls the setRepresentation function from a {@link ResourceStore}.
*/
export class PutOperationHandler extends OperationHandler {
protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore;
public constructor(store: ResourceStore) {
super();
this.store = store;
}
public async canHandle({ operation }: OperationHandlerInput): Promise<void> {
if (operation.method !== 'PUT') {
throw new NotImplementedHttpError('This handler only supports PUT operations');
}
}
public async handle({ operation }: OperationHandlerInput): Promise<ResponseDescription> {
// Solid, §2.1: "A Solid server MUST reject PUT, POST and PATCH requests
// without the Content-Type header with a status code of 400."
// https://solid.github.io/specification/protocol#http-server
if (!operation.body?.metadata.contentType) {
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(operation.target, operation.body, operation.conditions);
return new ResetResponseDescription();
}
}

View File

@@ -0,0 +1,19 @@
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { Operation } from '../../Operation';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
export interface OperationMetadataCollectorInput {
/**
* Metadata to update with permission knowledge.
*/
metadata: RepresentationMetadata;
/**
* Operation corresponding to the request.
*/
operation: Operation;
}
/**
* Adds metadata about the operation to the provided metadata object.
*/
export abstract class OperationMetadataCollector extends AsyncHandler<OperationMetadataCollectorInput> {}

View File

@@ -0,0 +1,38 @@
import { AclMode } from '../../../authorization/permissions/AclPermission';
import type { AclPermission } from '../../../authorization/permissions/AclPermission';
import { AccessMode } from '../../../authorization/permissions/Permissions';
import { ACL, AUTH } from '../../../util/Vocabularies';
import type { OperationMetadataCollectorInput } from './OperationMetadataCollector';
import { OperationMetadataCollector } from './OperationMetadataCollector';
const VALID_METHODS = new Set([ 'HEAD', 'GET' ]);
const VALID_ACL_MODES = new Set([ AccessMode.read, AccessMode.write, AccessMode.append, AclMode.control ]);
/**
* Indicates which acl permissions are available on the requested resource.
* Only adds public and agent permissions for HEAD/GET requests.
*/
export class WebAclMetadataCollector extends OperationMetadataCollector {
public async handle({ metadata, operation }: OperationMetadataCollectorInput): Promise<void> {
if (!operation.permissionSet || !VALID_METHODS.has(operation.method)) {
return;
}
const user: AclPermission = operation.permissionSet.agent ?? {};
const everyone: AclPermission = operation.permissionSet.public ?? {};
const modes = new Set<AccessMode>([ ...Object.keys(user), ...Object.keys(everyone) ] as AccessMode[]);
for (const mode of modes) {
if (VALID_ACL_MODES.has(mode)) {
const capitalizedMode = mode.charAt(0).toUpperCase() + mode.slice(1) as 'Read' | 'Write' | 'Append' | 'Control';
if (everyone[mode]) {
metadata.add(AUTH.terms.publicMode, ACL.terms[capitalizedMode]);
}
if (user[mode]) {
metadata.add(AUTH.terms.userMode, ACL.terms[capitalizedMode]);
}
}
}
}
}

View File

@@ -0,0 +1,46 @@
import { getLoggerFor } from '../../logging/LogUtil';
import type { HttpResponse } from '../../server/HttpResponse';
import { isInternalContentType } from '../../storage/conversion/ConversionUtil';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { pipeSafely } from '../../util/StreamUtil';
import type { MetadataWriter } from './metadata/MetadataWriter';
import type { ResponseDescription } from './response/ResponseDescription';
import { ResponseWriter } from './ResponseWriter';
/**
* Writes to an {@link HttpResponse} based on the incoming {@link ResponseDescription}.
*/
export class BasicResponseWriter extends ResponseWriter {
protected readonly logger = getLoggerFor(this);
private readonly metadataWriter: MetadataWriter;
public constructor(metadataWriter: MetadataWriter) {
super();
this.metadataWriter = metadataWriter;
}
public async canHandle(input: { response: HttpResponse; result: ResponseDescription }): Promise<void> {
const contentType = input.result.metadata?.contentType;
if (isInternalContentType(contentType)) {
throw new NotImplementedHttpError(`Cannot serialize the internal content type ${contentType}`);
}
}
public async handle(input: { response: HttpResponse; result: ResponseDescription }): Promise<void> {
if (input.result.metadata) {
await this.metadataWriter.handleSafe({ response: input.response, metadata: input.result.metadata });
}
input.response.writeHead(input.result.statusCode);
if (input.result.data) {
const pipe = pipeSafely(input.result.data, input.response);
pipe.on('error', (error): void => {
this.logger.error(`Writing to HttpResponse failed with message ${error.message}`);
});
} else {
// If there is input data the response will end once the input stream ends
input.response.end();
}
}
}

View File

@@ -0,0 +1,9 @@
import type { HttpResponse } from '../../server/HttpResponse';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { ResponseDescription } from './response/ResponseDescription';
/**
* Writes the ResponseDescription to the HttpResponse.
*/
export abstract class ResponseWriter
extends AsyncHandler<{ response: HttpResponse; result: ResponseDescription }> {}

View File

@@ -0,0 +1,94 @@
import type {
RepresentationConverter,
RepresentationConverterArgs,
} from '../../../storage/conversion/RepresentationConverter';
import { INTERNAL_ERROR } from '../../../util/ContentTypes';
import { getStatusCode } from '../../../util/errors/ErrorUtil';
import { toLiteral } from '../../../util/TermUtil';
import { HTTP, XSD } from '../../../util/Vocabularies';
import { BasicRepresentation } from '../../representation/BasicRepresentation';
import type { Representation } from '../../representation/Representation';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import type { ResponseDescription } from '../response/ResponseDescription';
import type { ErrorHandlerArgs } from './ErrorHandler';
import { ErrorHandler } from './ErrorHandler';
// Used by internal helper function
type PreparedArguments = {
statusCode: number;
conversionArgs: RepresentationConverterArgs;
};
/**
* Converts an error into a Representation of content type internal/error.
* Then feeds that representation into its converter to create a representation based on the given preferences.
*/
export class ConvertingErrorHandler extends ErrorHandler {
private readonly converter: RepresentationConverter;
private readonly showStackTrace: boolean;
public constructor(converter: RepresentationConverter, showStackTrace = false) {
super();
this.converter = converter;
this.showStackTrace = showStackTrace;
}
public async canHandle(input: ErrorHandlerArgs): Promise<void> {
const { conversionArgs } = this.prepareArguments(input);
await this.converter.canHandle(conversionArgs);
}
public async handle(input: ErrorHandlerArgs): Promise<ResponseDescription> {
const { statusCode, conversionArgs } = this.prepareArguments(input);
const converted = await this.converter.handle(conversionArgs);
return this.createResponse(statusCode, converted);
}
public async handleSafe(input: ErrorHandlerArgs): Promise<ResponseDescription> {
const { statusCode, conversionArgs } = this.prepareArguments(input);
const converted = await this.converter.handleSafe(conversionArgs);
return this.createResponse(statusCode, converted);
}
/**
* Prepares the arguments used by all functions.
*/
private prepareArguments({ error, preferences }: ErrorHandlerArgs): PreparedArguments {
const statusCode = getStatusCode(error);
const representation = this.toRepresentation(error, statusCode);
const identifier = { path: representation.metadata.identifier.value };
return { statusCode, conversionArgs: { identifier, representation, preferences }};
}
/**
* Creates a ResponseDescription based on the Representation.
*/
private createResponse(statusCode: number, converted: Representation): ResponseDescription {
return {
statusCode,
metadata: converted.metadata,
data: converted.data,
};
}
/**
* Creates a Representation based on the given error.
* Content type will be internal/error.
* The status code is used for metadata.
*/
private toRepresentation(error: Error, statusCode: number): Representation {
const metadata = new RepresentationMetadata(INTERNAL_ERROR);
metadata.add(HTTP.terms.statusCodeNumber, toLiteral(statusCode, XSD.terms.integer));
if (!this.showStackTrace) {
delete error.stack;
}
return new BasicRepresentation([ error ], metadata, false);
}
}

View File

@@ -0,0 +1,13 @@
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { RepresentationPreferences } from '../../representation/RepresentationPreferences';
import type { ResponseDescription } from '../response/ResponseDescription';
export interface ErrorHandlerArgs {
error: Error;
preferences: RepresentationPreferences;
}
/**
* Converts an error into a {@link ResponseDescription} based on the request preferences.
*/
export abstract class ErrorHandler extends AsyncHandler<ErrorHandlerArgs, ResponseDescription> {}

View File

@@ -0,0 +1,48 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import { createErrorMessage, getStatusCode } from '../../../util/errors/ErrorUtil';
import { guardedStreamFrom } from '../../../util/StreamUtil';
import { toLiteral } from '../../../util/TermUtil';
import { HTTP, XSD } from '../../../util/Vocabularies';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import type { ResponseDescription } from '../response/ResponseDescription';
import type { ErrorHandlerArgs } from './ErrorHandler';
import { ErrorHandler } from './ErrorHandler';
/**
* Returns a simple text description of an error.
* This class is a failsafe in case the wrapped error handler fails.
*/
export class SafeErrorHandler extends ErrorHandler {
protected readonly logger = getLoggerFor(this);
private readonly errorHandler: ErrorHandler;
private readonly showStackTrace: boolean;
public constructor(errorHandler: ErrorHandler, showStackTrace = false) {
super();
this.errorHandler = errorHandler;
this.showStackTrace = showStackTrace;
}
public async handle(input: ErrorHandlerArgs): Promise<ResponseDescription> {
try {
return await this.errorHandler.handleSafe(input);
} catch (error: unknown) {
this.logger.debug(`Recovering from error handler failure: ${createErrorMessage(error)}`);
}
const { error } = input;
const statusCode = getStatusCode(error);
const metadata = new RepresentationMetadata('text/plain');
metadata.add(HTTP.terms.statusCodeNumber, toLiteral(statusCode, XSD.terms.integer));
const text = typeof error.stack === 'string' && this.showStackTrace ?
`${error.stack}\n` :
`${error.name}: ${error.message}\n`;
return {
statusCode,
metadata,
data: guardedStreamFrom(text),
};
}
}

View File

@@ -0,0 +1,21 @@
import type { HttpResponse } from '../../../server/HttpResponse';
import { addHeader } from '../../../util/HeaderUtil';
import { MetadataWriter } from './MetadataWriter';
/**
* A {@link MetadataWriter} that takes a constant map of header names and values.
*/
export class ConstantMetadataWriter extends MetadataWriter {
private readonly headers: [string, string][];
public constructor(headers: Record<string, string>) {
super();
this.headers = Object.entries(headers);
}
public async handle({ response }: { response: HttpResponse }): Promise<void> {
for (const [ key, value ] of this.headers) {
addHeader(response, key, value);
}
}
}

View File

@@ -0,0 +1,31 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpResponse } from '../../../server/HttpResponse';
import { addHeader } from '../../../util/HeaderUtil';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';
/**
* A {@link MetadataWriter} that takes a linking metadata predicates to Link header "rel" values.
* The values of the objects will be put in a Link header with the corresponding "rel" value.
*/
export class LinkRelMetadataWriter extends MetadataWriter {
private readonly linkRelMap: Record<string, string>;
protected readonly logger = getLoggerFor(this);
public constructor(linkRelMap: Record<string, string>) {
super();
this.linkRelMap = linkRelMap;
}
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
const keys = Object.keys(this.linkRelMap);
this.logger.debug(`Available link relations: ${keys.length}`);
for (const key of keys) {
const values = input.metadata.getAll(key).map((term): string => `<${term.value}>; rel="${this.linkRelMap[key]}"`);
if (values.length > 0) {
this.logger.debug(`Adding Link header ${values}`);
addHeader(input.response, 'Link', values);
}
}
}
}

View File

@@ -0,0 +1,26 @@
import type { HttpResponse } from '../../../server/HttpResponse';
import { addHeader } from '../../../util/HeaderUtil';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';
/**
* A {@link MetadataWriter} that takes a map directly converting metadata predicates to headers.
* The header value(s) will be the same as the corresponding object value(s).
*/
export class MappedMetadataWriter extends MetadataWriter {
private readonly headerMap: [string, string][];
public constructor(headerMap: Record<string, string>) {
super();
this.headerMap = Object.entries(headerMap);
}
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
for (const [ predicate, header ] of this.headerMap) {
const terms = input.metadata.getAll(predicate);
if (terms.length > 0) {
addHeader(input.response, header, terms.map((term): string => term.value));
}
}
}
}

View File

@@ -0,0 +1,9 @@
import type { HttpResponse } from '../../../server/HttpResponse';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
/**
* A serializer that converts metadata to headers for an HttpResponse.
*/
export abstract class MetadataWriter
extends AsyncHandler<{ response: HttpResponse; metadata: RepresentationMetadata }> { }

View File

@@ -0,0 +1,23 @@
import type { HttpResponse } from '../../../server/HttpResponse';
import { getETag } from '../../../storage/Conditions';
import { addHeader } from '../../../util/HeaderUtil';
import { DC } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';
/**
* A {@link MetadataWriter} that generates all the necessary headers related to the modification date of a resource.
*/
export class ModifiedMetadataWriter extends MetadataWriter {
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
const modified = input.metadata.get(DC.terms.modified);
if (modified) {
const date = new Date(modified.value);
addHeader(input.response, 'Last-Modified', date.toUTCString());
}
const etag = getETag(input.metadata);
if (etag) {
addHeader(input.response, 'ETag', etag);
}
}
}

View File

@@ -0,0 +1,44 @@
import type { Term } from 'rdf-js';
import type { HttpResponse } from '../../../server/HttpResponse';
import { addHeader } from '../../../util/HeaderUtil';
import { ACL, AUTH } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';
/**
* Add the necessary WAC-Allow header values.
* Solid, §10.1: "Servers exposing clients access privileges on a resource URL MUST advertise
* by including the WAC-Allow HTTP header in the response of HTTP HEAD and GET requests."
* https://solid.github.io/specification/protocol#web-access-control
*/
export class WacAllowMetadataWriter extends MetadataWriter {
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
let userModes = new Set(input.metadata.getAll(AUTH.terms.userMode).map(this.aclToPermission));
const publicModes = new Set(input.metadata.getAll(AUTH.terms.publicMode).map(this.aclToPermission));
// Public access implies user access
userModes = new Set([ ...userModes, ...publicModes ]);
const headerStrings: string[] = [];
if (userModes.size > 0) {
headerStrings.push(this.createAccessParam('user', userModes));
}
if (publicModes.size > 0) {
headerStrings.push(this.createAccessParam('public', publicModes));
}
// Only add the header if there are permissions to show
if (headerStrings.length > 0) {
addHeader(input.response, 'WAC-Allow', headerStrings.join(','));
}
}
private aclToPermission(aclTerm: Term): string {
return aclTerm.value.slice(ACL.namespace.length).toLowerCase();
}
private createAccessParam(name: string, modes: Set<string>): string {
// Sort entries to have consistent output
return `${name}="${[ ...modes ].sort((left, right): number => left.localeCompare(right)).join(' ')}"`;
}
}

View File

@@ -0,0 +1,24 @@
import type { HttpResponse } from '../../../server/HttpResponse';
import { addHeader } from '../../../util/HeaderUtil';
import { HTTP } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';
/**
* Adds the `WWW-Authenticate` header with the injected value in case the response status code is 401.
*/
export class WwwAuthMetadataWriter extends MetadataWriter {
private readonly auth: string;
public constructor(auth: string) {
super();
this.auth = auth;
}
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
const statusLiteral = input.metadata.get(HTTP.terms.statusCodeNumber);
if (statusLiteral?.value === '401') {
addHeader(input.response, 'WWW-Authenticate', this.auth);
}
}
}

View File

@@ -0,0 +1,15 @@
import { DataFactory } from 'n3';
import { SOLID_HTTP } from '../../../util/Vocabularies';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../representation/ResourceIdentifier';
import { ResponseDescription } from './ResponseDescription';
/**
* Corresponds to a 201 response, containing the relevant location metadata.
*/
export class CreatedResponseDescription extends ResponseDescription {
public constructor(location: ResourceIdentifier) {
const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(location.path) });
super(201, metadata);
}
}

View File

@@ -0,0 +1,17 @@
import type { Readable } from 'stream';
import type { Guarded } from '../../../util/GuardedStream';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { ResponseDescription } from './ResponseDescription';
/**
* Corresponds to a 200 response, containing relevant metadata and potentially data.
*/
export class OkResponseDescription extends ResponseDescription {
/**
* @param metadata - Metadata concerning the response.
* @param data - Potential data. @ignored
*/
public constructor(metadata: RepresentationMetadata, data?: Guarded<Readable>) {
super(200, metadata, data);
}
}

View File

@@ -0,0 +1,14 @@
import { DataFactory } from 'n3';
import { SOLID_HTTP } from '../../../util/Vocabularies';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { ResponseDescription } from './ResponseDescription';
/**
* Corresponds to a 301/302 response, containing the relevant location metadata.
*/
export class RedirectResponseDescription extends ResponseDescription {
public constructor(location: string, permanently = false) {
const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(location) });
super(permanently ? 301 : 302, metadata);
}
}

View File

@@ -0,0 +1,10 @@
import { ResponseDescription } from './ResponseDescription';
/**
* Corresponds to a 205 response.
*/
export class ResetResponseDescription extends ResponseDescription {
public constructor() {
super(205);
}
}

View File

@@ -0,0 +1,23 @@
import type { Readable } from 'stream';
import type { Guarded } from '../../../util/GuardedStream';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
/**
* The result of executing an operation.
*/
export class ResponseDescription {
public readonly statusCode: number;
public readonly metadata?: RepresentationMetadata;
public readonly data?: Guarded<Readable>;
/**
* @param statusCode - Status code to return.
* @param metadata - Metadata corresponding to the response (and data potentially).
* @param data - Data that needs to be returned. @ignored
*/
public constructor(statusCode: number, metadata?: RepresentationMetadata, data?: Guarded<Readable>) {
this.statusCode = statusCode;
this.metadata = metadata;
this.data = data;
}
}

View File

@@ -0,0 +1,113 @@
import type { Readable } from 'stream';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import type { Guarded } from '../../util/GuardedStream';
import { guardStream } from '../../util/GuardedStream';
import { guardedStreamFrom } from '../../util/StreamUtil';
import type { Representation } from './Representation';
import type { MetadataIdentifier, MetadataRecord } from './RepresentationMetadata';
import { RepresentationMetadata, isRepresentationMetadata } from './RepresentationMetadata';
/**
* Class with various constructors to facilitate creating a representation.
*
* A representation consists of 1) data, 2) metadata, and 3) a binary flag
* to indicate whether the data is a binary stream or an object stream.
*
* 1. The data can be given as a stream, array, or string.
* 2. The metadata can be specified as one or two parameters
* that will be passed to the {@link RepresentationMetadata} constructor.
* 3. The binary field is optional, and if not specified,
* is determined from the content type inside the metadata.
*/
export class BasicRepresentation implements Representation {
public readonly data: Guarded<Readable>;
public readonly metadata: RepresentationMetadata;
public readonly binary: boolean;
/**
* @param data - The representation data
* @param metadata - The representation metadata
* @param binary - Whether the representation is a binary or object stream
*/
public constructor(
data: Guarded<Readable> | Readable | any[] | string,
metadata: RepresentationMetadata | MetadataRecord,
binary?: boolean,
);
/**
* @param data - The representation data
* @param metadata - The representation metadata
* @param contentType - The representation's content type
* @param binary - Whether the representation is a binary or object stream
*/
public constructor(
data: Guarded<Readable> | Readable | any[] | string,
metadata: RepresentationMetadata | MetadataRecord,
contentType?: string,
binary?: boolean,
);
/**
* @param data - The representation data
* @param contentType - The representation's content type
* @param binary - Whether the representation is a binary or object stream
*/
public constructor(
data: Guarded<Readable> | Readable | any[] | string,
contentType: string,
binary?: boolean,
);
/**
* @param data - The representation data
* @param identifier - The representation's identifier
* @param metadata - The representation metadata
* @param binary - Whether the representation is a binary or object stream
*/
public constructor(
data: Guarded<Readable> | Readable | any[] | string,
identifier: MetadataIdentifier,
metadata?: MetadataRecord,
binary?: boolean,
);
/**
* @param data - The representation data
* @param identifier - The representation's identifier
* @param contentType - The representation's content type
* @param binary - Whether the representation is a binary or object stream
*/
public constructor(
data: Guarded<Readable> | Readable | any[] | string,
identifier: MetadataIdentifier,
contentType?: string,
binary?: boolean,
);
public constructor(
data: Readable | any[] | string,
metadata: RepresentationMetadata | MetadataRecord | MetadataIdentifier | string,
metadataRest?: MetadataRecord | string | boolean,
binary?: boolean,
) {
if (typeof data === 'string' || Array.isArray(data)) {
data = guardedStreamFrom(data);
}
this.data = guardStream(data);
if (typeof metadataRest === 'boolean') {
binary = metadataRest;
metadataRest = undefined;
}
if (!isRepresentationMetadata(metadata) || typeof metadataRest === 'string') {
metadata = new RepresentationMetadata(metadata as any, metadataRest as any);
}
this.metadata = metadata;
if (typeof binary !== 'boolean') {
binary = metadata.contentType !== INTERNAL_QUADS;
}
this.binary = binary;
}
}

View File

@@ -0,0 +1,7 @@
import type { Representation } from './Representation';
/**
* Represents the changes needed for a PATCH request.
*/
export interface Patch extends Representation {
}

View File

@@ -0,0 +1,22 @@
import type { Readable } from 'stream';
import type { Guarded } from '../../util/GuardedStream';
import type { RepresentationMetadata } from './RepresentationMetadata';
/**
* A representation of a resource.
*/
export interface Representation {
/**
* The corresponding metadata.
*/
metadata: RepresentationMetadata;
/**
* The raw data stream for this representation.
*/
data: Guarded<Readable>;
/**
* Whether the data stream consists of binary/string chunks
* (as opposed to complex objects).
*/
binary: boolean;
}

View File

@@ -0,0 +1,319 @@
import { DataFactory, Store } from 'n3';
import type { BlankNode, DefaultGraph, Literal, NamedNode, Quad, Term } from 'rdf-js';
import { getLoggerFor } from '../../logging/LogUtil';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { toNamedTerm, toObjectTerm, toCachedNamedNode, isTerm } from '../../util/TermUtil';
import { CONTENT_TYPE, CONTENT_TYPE_TERM } from '../../util/Vocabularies';
import type { ResourceIdentifier } from './ResourceIdentifier';
import { isResourceIdentifier } from './ResourceIdentifier';
export type MetadataIdentifier = ResourceIdentifier | NamedNode | BlankNode;
export type MetadataValue = NamedNode | Literal | string | (NamedNode | Literal | string)[];
export type MetadataRecord = Record<string, MetadataValue>;
export type MetadataGraph = NamedNode | BlankNode | DefaultGraph | string;
/**
* Determines whether the object is a `RepresentationMetadata`.
*/
export function isRepresentationMetadata(object: any): object is RepresentationMetadata {
return typeof object?.setMetadata === 'function';
}
/**
* Stores the metadata triples and provides methods for easy access.
* Most functions return the metadata object to allow for chaining.
*/
export class RepresentationMetadata {
protected readonly logger = getLoggerFor(this);
private store: Store;
private id: NamedNode | BlankNode;
/**
* @param identifier - Identifier of the resource relevant to this metadata.
* A blank node will be generated if none is provided.
* Strings will be converted to named nodes. @ignored
* @param overrides - Key/value map of extra values that need to be added to the metadata. @ignored
*
* `@ignored` tag is necessary for Components-Generator.js
*/
public constructor(identifier?: MetadataIdentifier, overrides?: MetadataRecord);
/**
* @param metadata - Starts as a copy of the input metadata.
* @param overrides - Key/value map of extra values that need to be added to the metadata.
* Will override values that were set by the input metadata.
*/
public constructor(metadata?: RepresentationMetadata, overrides?: MetadataRecord);
/**
* @param identifier - Identifier of the resource relevant to this metadata.
* @param contentType - Override for the content type of the representation.
*/
public constructor(identifier?: MetadataIdentifier, contentType?: string);
/**
* @param metadata - Starts as a copy of the input metadata.
* @param contentType - Override for the content type of the representation.
*/
public constructor(metadata?: RepresentationMetadata, contentType?: string);
/**
* @param contentType - The content type of the representation.
*/
public constructor(contentType?: string);
/**
* @param metadata - Metadata values (defaulting to content type if a string)
*/
public constructor(metadata?: RepresentationMetadata | MetadataRecord | string);
public constructor(
input?: MetadataIdentifier | RepresentationMetadata | MetadataRecord | string,
overrides?: MetadataRecord | string,
) {
this.store = new Store();
if (isResourceIdentifier(input)) {
this.id = DataFactory.namedNode(input.path);
} else if (isTerm(input)) {
this.id = input;
} else if (isRepresentationMetadata(input)) {
this.id = input.identifier;
this.addQuads(input.quads());
} else {
overrides = input;
this.id = this.store.createBlankNode();
}
if (overrides) {
if (typeof overrides === 'string') {
overrides = { [CONTENT_TYPE]: overrides };
}
this.setOverrides(overrides);
}
}
private setOverrides(overrides: Record<string, MetadataValue>): void {
for (const predicate of Object.keys(overrides)) {
const namedPredicate = toCachedNamedNode(predicate);
this.removeAll(namedPredicate);
let objects = overrides[predicate];
if (!Array.isArray(objects)) {
objects = [ objects ];
}
for (const object of objects) {
this.store.addQuad(this.id, namedPredicate, toObjectTerm(object, true));
}
}
}
/**
* @returns All matching metadata quads.
*/
public quads(
subject: NamedNode | BlankNode | string | null = null,
predicate: NamedNode | string | null = null,
object: NamedNode | BlankNode | Literal | string | null = null,
graph: MetadataGraph | null = null,
): Quad[] {
return this.store.getQuads(subject, predicate, object, graph);
}
/**
* Identifier of the resource this metadata is relevant to.
* Will update all relevant triples if this value gets changed.
*/
public get identifier(): NamedNode | BlankNode {
return this.id;
}
public set identifier(id: NamedNode | BlankNode) {
if (!id.equals(this.id)) {
// Convert all instances of the old identifier to the new identifier in the stored quads
const quads = this.quads().map((quad): Quad => {
if (quad.subject.equals(this.id)) {
return DataFactory.quad(id, quad.predicate, quad.object, quad.graph);
}
if (quad.object.equals(this.id)) {
return DataFactory.quad(quad.subject, quad.predicate, id, quad.graph);
}
return quad;
});
this.store = new Store(quads);
this.id = id;
}
}
/**
* Helper function to import all entries from the given metadata.
* If the new metadata has a different identifier the internal one will be updated.
* @param metadata - Metadata to import.
*/
public setMetadata(metadata: RepresentationMetadata): this {
this.identifier = metadata.identifier;
this.addQuads(metadata.quads());
return this;
}
/**
* @param subject - Subject of quad to add.
* @param predicate - Predicate of quad to add.
* @param object - Object of quad to add.
* @param graph - Optional graph of quad to add.
*/
public addQuad(
subject: NamedNode | BlankNode | string,
predicate: NamedNode | string,
object: NamedNode | BlankNode | Literal | string,
graph?: MetadataGraph,
): this {
this.store.addQuad(toNamedTerm(subject),
toCachedNamedNode(predicate),
toObjectTerm(object, true),
graph ? toNamedTerm(graph) : undefined);
return this;
}
/**
* @param quads - Quads to add to the metadata.
*/
public addQuads(quads: Quad[]): this {
this.store.addQuads(quads);
return this;
}
/**
* @param subject - Subject of quad to remove.
* @param predicate - Predicate of quad to remove.
* @param object - Object of quad to remove.
* @param graph - Optional graph of quad to remove.
*/
public removeQuad(
subject: NamedNode | BlankNode | string,
predicate: NamedNode | string,
object: NamedNode | BlankNode | Literal | string,
graph?: MetadataGraph,
): this {
const quads = this.quads(toNamedTerm(subject),
toCachedNamedNode(predicate),
toObjectTerm(object, true),
graph ? toNamedTerm(graph) : undefined);
return this.removeQuads(quads);
}
/**
* @param quads - Quads to remove from the metadata.
*/
public removeQuads(quads: Quad[]): this {
this.store.removeQuads(quads);
return this;
}
/**
* Adds a value linked to the identifier. Strings get converted to literals.
* @param predicate - Predicate linking identifier to value.
* @param object - Value(s) to add.
* @param graph - Optional graph of where to add the values to.
*/
public add(predicate: NamedNode | string, object: MetadataValue, graph?: MetadataGraph): this {
return this.forQuads(predicate, object, (pred, obj): any => this.addQuad(this.id, pred, obj, graph));
}
/**
* Removes the given value from the metadata. Strings get converted to literals.
* @param predicate - Predicate linking identifier to value.
* @param object - Value(s) to remove.
* @param graph - Optional graph of where to remove the values from.
*/
public remove(predicate: NamedNode | string, object: MetadataValue, graph?: MetadataGraph): this {
return this.forQuads(predicate, object, (pred, obj): any => this.removeQuad(this.id, pred, obj, graph));
}
/**
* Helper function to simplify add/remove
* Runs the given function on all predicate/object pairs, but only converts the predicate to a named node once.
*/
private forQuads(predicate: NamedNode | string, object: MetadataValue,
forFn: (pred: NamedNode, obj: NamedNode | Literal) => void): this {
const predicateNode = toCachedNamedNode(predicate);
const objects = Array.isArray(object) ? object : [ object ];
for (const obj of objects) {
forFn(predicateNode, toObjectTerm(obj, true));
}
return this;
}
/**
* Removes all values linked through the given predicate.
* @param predicate - Predicate to remove.
* @param graph - Optional graph where to remove from.
*/
public removeAll(predicate: NamedNode | string, graph?: MetadataGraph): this {
this.removeQuads(this.store.getQuads(this.id, toCachedNamedNode(predicate), null, graph ?? null));
return this;
}
/**
* Finds all object values matching the given predicate and/or graph.
* @param predicate - Optional predicate to get the values for.
* @param graph - Optional graph where to get from.
*
* @returns An array with all matches.
*/
public getAll(predicate: NamedNode | string, graph?: MetadataGraph): Term[] {
return this.store.getQuads(this.id, toCachedNamedNode(predicate), null, graph ?? null)
.map((quad): Term => quad.object);
}
/**
* @param predicate - Predicate to get the value for.
* @param graph - Optional graph where the triple should be found.
*
* @throws Error
* If there are multiple matching values.
*
* @returns The corresponding value. Undefined if there is no match
*/
public get(predicate: NamedNode | string, graph?: MetadataGraph): Term | undefined {
const terms = this.getAll(predicate, graph);
if (terms.length === 0) {
return;
}
if (terms.length > 1) {
this.logger.error(`Multiple results for ${typeof predicate === 'string' ? predicate : predicate.value}`);
throw new InternalServerError(
`Multiple results for ${typeof predicate === 'string' ? predicate : predicate.value}`,
);
}
return terms[0];
}
/**
* Sets the value for the given predicate, removing all other instances.
* In case the object is undefined this is identical to `removeAll(predicate)`.
* @param predicate - Predicate linking to the value.
* @param object - Value(s) to set.
* @param graph - Optional graph where the triple should be stored.
*/
public set(predicate: NamedNode | string, object?: MetadataValue, graph?: MetadataGraph): this {
this.removeAll(predicate, graph);
if (object) {
this.add(predicate, object, graph);
}
return this;
}
// Syntactic sugar for common predicates
/**
* Shorthand for the CONTENT_TYPE predicate.
*/
public get contentType(): string | undefined {
return this.get(CONTENT_TYPE_TERM)?.value;
}
public set contentType(input) {
this.set(CONTENT_TYPE_TERM, input);
}
}

View File

@@ -0,0 +1,30 @@
/**
* Represents preferred values along a single content negotiation dimension.
*
* The number represents how preferred this value is from 0 to 1.
* Follows the quality values rule from RFC 7231:
* "The weight is normalized to a real number in the range 0 through 1,
* where 0.001 is the least preferred and 1 is the most preferred; a
* value of 0 means "not acceptable"."
*/
export type ValuePreferences = Record<string, number>;
/**
* A single entry of a {@link ValuePreferences} object.
* Useful when doing operations on such an object.
*/
export type ValuePreference = { value: string; weight: number };
/**
* Contains preferences along multiple content negotiation dimensions.
*
* All dimensions are optional for ease of constructing; either `undefined`
* or an empty `ValuePreferences` can indicate that no preferences were specified.
*/
export interface RepresentationPreferences {
type?: ValuePreferences;
charset?: ValuePreferences;
datetime?: ValuePreferences;
encoding?: ValuePreferences;
language?: ValuePreferences;
}

View File

@@ -0,0 +1,16 @@
/**
* The unique identifier of a resource.
*/
export interface ResourceIdentifier {
/**
* Path to the relevant resource.
*/
path: string;
}
/**
* Determines whether the object is a `ResourceIdentifier`.
*/
export function isResourceIdentifier(object: any): object is ResourceIdentifier {
return object && (typeof object.path === 'string');
}

View File

@@ -0,0 +1,12 @@
import type { Algebra } from 'sparqlalgebrajs';
import type { Patch } from './Patch';
/**
* A specific type of {@link Patch} corresponding to a SPARQL update.
*/
export interface SparqlUpdatePatch extends Patch {
/**
* Algebra corresponding to the SPARQL update.
*/
algebra: Algebra.Update;
}