mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
refactor: Restructure source code folder
This way the location of certain classes should make more sense
This commit is contained in:
35
src/http/Operation.ts
Normal file
35
src/http/Operation.ts
Normal 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;
|
||||
}
|
||||
146
src/http/UnsecureWebSocketsProtocol.ts
Normal file
146
src/http/UnsecureWebSocketsProtocol.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/http/auxiliary/AuxiliaryIdentifierStrategy.ts
Normal file
48
src/http/auxiliary/AuxiliaryIdentifierStrategy.ts
Normal 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;
|
||||
}
|
||||
49
src/http/auxiliary/AuxiliaryStrategy.ts
Normal file
49
src/http/auxiliary/AuxiliaryStrategy.ts
Normal 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>;
|
||||
}
|
||||
64
src/http/auxiliary/ComposedAuxiliaryStrategy.ts
Normal file
64
src/http/auxiliary/ComposedAuxiliaryStrategy.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/http/auxiliary/LinkMetadataGenerator.ts
Normal file
31
src/http/auxiliary/LinkMetadataGenerator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/http/auxiliary/MetadataGenerator.ts
Normal file
7
src/http/auxiliary/MetadataGenerator.ts
Normal 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> { }
|
||||
43
src/http/auxiliary/RdfValidator.ts
Normal file
43
src/http/auxiliary/RdfValidator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
44
src/http/auxiliary/RoutingAuxiliaryIdentifierStrategy.ts
Normal file
44
src/http/auxiliary/RoutingAuxiliaryIdentifierStrategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
54
src/http/auxiliary/RoutingAuxiliaryStrategy.ts
Normal file
54
src/http/auxiliary/RoutingAuxiliaryStrategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
src/http/auxiliary/SuffixAuxiliaryIdentifierStrategy.ts
Normal file
37
src/http/auxiliary/SuffixAuxiliaryIdentifierStrategy.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
7
src/http/auxiliary/Validator.ts
Normal file
7
src/http/auxiliary/Validator.ts
Normal 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> { }
|
||||
54
src/http/input/BasicRequestParser.ts
Normal file
54
src/http/input/BasicRequestParser.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
8
src/http/input/RequestParser.ts
Normal file
8
src/http/input/RequestParser.ts
Normal 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> {}
|
||||
22
src/http/input/body/BodyParser.ts
Normal file
22
src/http/input/body/BodyParser.ts
Normal 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> {}
|
||||
41
src/http/input/body/RawBodyParser.ts
Normal file
41
src/http/input/body/RawBodyParser.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
43
src/http/input/body/SparqlUpdateBodyParser.ts
Normal file
43
src/http/input/body/SparqlUpdateBodyParser.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
63
src/http/input/conditions/BasicConditionsParser.ts
Normal file
63
src/http/input/conditions/BasicConditionsParser.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||
import type { HttpRequest } from '../../../server/HttpRequest';
|
||||
import type { BasicConditionsOptions } from '../../../storage/BasicConditions';
|
||||
import { BasicConditions } from '../../../storage/BasicConditions';
|
||||
import type { Conditions } from '../../../storage/Conditions';
|
||||
import { ConditionsParser } from './ConditionsParser';
|
||||
|
||||
/**
|
||||
* Creates a Conditions object based on the the following headers:
|
||||
* - If-Modified-Since
|
||||
* - If-Unmodified-Since
|
||||
* - If-Match
|
||||
* - If-None-Match
|
||||
*
|
||||
* Implementation based on RFC7232
|
||||
*/
|
||||
export class BasicConditionsParser extends ConditionsParser {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
public async handle(request: HttpRequest): Promise<Conditions | undefined> {
|
||||
const options: BasicConditionsOptions = {
|
||||
matchesETag: this.parseTagHeader(request, 'if-match'),
|
||||
notMatchesETag: this.parseTagHeader(request, 'if-none-match'),
|
||||
};
|
||||
|
||||
// A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field
|
||||
// A recipient MUST ignore the If-Modified-Since header field ... if the request method is neither GET nor HEAD.
|
||||
if (!options.notMatchesETag && (request.method === 'GET' || request.method === 'HEAD')) {
|
||||
options.modifiedSince = this.parseDateHeader(request, 'if-modified-since');
|
||||
}
|
||||
|
||||
// A recipient MUST ignore If-Unmodified-Since if the request contains an If-Match header field
|
||||
if (!options.matchesETag) {
|
||||
options.unmodifiedSince = this.parseDateHeader(request, 'if-unmodified-since');
|
||||
}
|
||||
|
||||
// Only return a Conditions object if there is at least one condition; undefined otherwise
|
||||
this.logger.debug(`Found the following conditions: ${JSON.stringify(options)}`);
|
||||
if (Object.values(options).some((val): boolean => typeof val !== 'undefined')) {
|
||||
return new BasicConditions(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a request header containing a datetime string to an actual Date object.
|
||||
* Undefined if there is no value for the given header name.
|
||||
*/
|
||||
private parseDateHeader(request: HttpRequest, header: 'if-modified-since' | 'if-unmodified-since'): Date | undefined {
|
||||
const headerVal = request.headers[header];
|
||||
if (headerVal) {
|
||||
const timestamp = Date.parse(headerVal);
|
||||
return Number.isNaN(timestamp) ? undefined : new Date(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a request header containing ETags to an array of ETags.
|
||||
* Undefined if there is no value for the given header name.
|
||||
*/
|
||||
private parseTagHeader(request: HttpRequest, header: 'if-match' | 'if-none-match'): string[] | undefined {
|
||||
return request.headers[header]?.trim().split(/\s*,\s*/u);
|
||||
}
|
||||
}
|
||||
8
src/http/input/conditions/ConditionsParser.ts
Normal file
8
src/http/input/conditions/ConditionsParser.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { HttpRequest } from '../../../server/HttpRequest';
|
||||
import type { Conditions } from '../../../storage/Conditions';
|
||||
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
||||
|
||||
/**
|
||||
* Creates a Conditions object based on the input HttpRequest.
|
||||
*/
|
||||
export abstract class ConditionsParser extends AsyncHandler<HttpRequest, Conditions | undefined> {}
|
||||
58
src/http/input/identifier/OriginalUrlExtractor.ts
Normal file
58
src/http/input/identifier/OriginalUrlExtractor.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
8
src/http/input/identifier/TargetExtractor.ts
Normal file
8
src/http/input/identifier/TargetExtractor.ts
Normal 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> {}
|
||||
17
src/http/input/metadata/ContentTypeParser.ts
Normal file
17
src/http/input/metadata/ContentTypeParser.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/http/input/metadata/LinkRelParser.ts
Normal file
48
src/http/input/metadata/LinkRelParser.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/http/input/metadata/MetadataParser.ts
Normal file
9
src/http/input/metadata/MetadataParser.ts
Normal 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 }> {}
|
||||
25
src/http/input/metadata/SlugParser.ts
Normal file
25
src/http/input/metadata/SlugParser.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/http/input/preferences/AcceptPreferenceParser.ts
Normal file
45
src/http/input/preferences/AcceptPreferenceParser.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
src/http/input/preferences/PreferenceParser.ts
Normal file
8
src/http/input/preferences/PreferenceParser.ts
Normal 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> {}
|
||||
30
src/http/ldp/DeleteOperationHandler.ts
Normal file
30
src/http/ldp/DeleteOperationHandler.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
31
src/http/ldp/GetOperationHandler.ts
Normal file
31
src/http/ldp/GetOperationHandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
src/http/ldp/HeadOperationHandler.ts
Normal file
34
src/http/ldp/HeadOperationHandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/http/ldp/OperationHandler.ts
Normal file
12
src/http/ldp/OperationHandler.ts
Normal 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> {}
|
||||
42
src/http/ldp/PatchOperationHandler.ts
Normal file
42
src/http/ldp/PatchOperationHandler.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
41
src/http/ldp/PostOperationHandler.ts
Normal file
41
src/http/ldp/PostOperationHandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
41
src/http/ldp/PutOperationHandler.ts
Normal file
41
src/http/ldp/PutOperationHandler.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
19
src/http/ldp/metadata/OperationMetadataCollector.ts
Normal file
19
src/http/ldp/metadata/OperationMetadataCollector.ts
Normal 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> {}
|
||||
38
src/http/ldp/metadata/WebAclMetadataCollector.ts
Normal file
38
src/http/ldp/metadata/WebAclMetadataCollector.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/http/output/BasicResponseWriter.ts
Normal file
46
src/http/output/BasicResponseWriter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/http/output/ResponseWriter.ts
Normal file
9
src/http/output/ResponseWriter.ts
Normal 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 }> {}
|
||||
94
src/http/output/error/ConvertingErrorHandler.ts
Normal file
94
src/http/output/error/ConvertingErrorHandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/http/output/error/ErrorHandler.ts
Normal file
13
src/http/output/error/ErrorHandler.ts
Normal 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> {}
|
||||
48
src/http/output/error/SafeErrorHandler.ts
Normal file
48
src/http/output/error/SafeErrorHandler.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
21
src/http/output/metadata/ConstantMetadataWriter.ts
Normal file
21
src/http/output/metadata/ConstantMetadataWriter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/http/output/metadata/LinkRelMetadataWriter.ts
Normal file
31
src/http/output/metadata/LinkRelMetadataWriter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/http/output/metadata/MappedMetadataWriter.ts
Normal file
26
src/http/output/metadata/MappedMetadataWriter.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/http/output/metadata/MetadataWriter.ts
Normal file
9
src/http/output/metadata/MetadataWriter.ts
Normal 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 }> { }
|
||||
23
src/http/output/metadata/ModifiedMetadataWriter.ts
Normal file
23
src/http/output/metadata/ModifiedMetadataWriter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/http/output/metadata/WacAllowMetadataWriter.ts
Normal file
44
src/http/output/metadata/WacAllowMetadataWriter.ts
Normal 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 client’s 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(' ')}"`;
|
||||
}
|
||||
}
|
||||
24
src/http/output/metadata/WwwAuthMetadataWriter.ts
Normal file
24
src/http/output/metadata/WwwAuthMetadataWriter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/http/output/response/CreatedResponseDescription.ts
Normal file
15
src/http/output/response/CreatedResponseDescription.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/http/output/response/OkResponseDescription.ts
Normal file
17
src/http/output/response/OkResponseDescription.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/http/output/response/RedirectResponseDescription.ts
Normal file
14
src/http/output/response/RedirectResponseDescription.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
src/http/output/response/ResetResponseDescription.ts
Normal file
10
src/http/output/response/ResetResponseDescription.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ResponseDescription } from './ResponseDescription';
|
||||
|
||||
/**
|
||||
* Corresponds to a 205 response.
|
||||
*/
|
||||
export class ResetResponseDescription extends ResponseDescription {
|
||||
public constructor() {
|
||||
super(205);
|
||||
}
|
||||
}
|
||||
23
src/http/output/response/ResponseDescription.ts
Normal file
23
src/http/output/response/ResponseDescription.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
113
src/http/representation/BasicRepresentation.ts
Normal file
113
src/http/representation/BasicRepresentation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
7
src/http/representation/Patch.ts
Normal file
7
src/http/representation/Patch.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Representation } from './Representation';
|
||||
|
||||
/**
|
||||
* Represents the changes needed for a PATCH request.
|
||||
*/
|
||||
export interface Patch extends Representation {
|
||||
}
|
||||
22
src/http/representation/Representation.ts
Normal file
22
src/http/representation/Representation.ts
Normal 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;
|
||||
}
|
||||
319
src/http/representation/RepresentationMetadata.ts
Normal file
319
src/http/representation/RepresentationMetadata.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/http/representation/RepresentationPreferences.ts
Normal file
30
src/http/representation/RepresentationPreferences.ts
Normal 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;
|
||||
}
|
||||
16
src/http/representation/ResourceIdentifier.ts
Normal file
16
src/http/representation/ResourceIdentifier.ts
Normal 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');
|
||||
}
|
||||
12
src/http/representation/SparqlUpdatePatch.ts
Normal file
12
src/http/representation/SparqlUpdatePatch.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user