Merge branch 'main' into versions/next-major

This commit is contained in:
Joachim Van Herwegen
2023-10-05 14:28:06 +02:00
58 changed files with 1061 additions and 215 deletions

View File

@@ -11,7 +11,7 @@ import type { RepresentationPreferences } from '../../representation/Representat
import { PreferenceParser } from './PreferenceParser';
const parsers: {
name: keyof RepresentationPreferences;
name: Exclude<keyof RepresentationPreferences, 'range'>;
header: string;
parse: (value: string) => AcceptHeader[];
}[] = [

View File

@@ -0,0 +1,48 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import type { RepresentationPreferences } from '../../representation/RepresentationPreferences';
import { PreferenceParser } from './PreferenceParser';
/**
* Parses the range header into range preferences.
* If the range corresponds to a suffix-length range, it will be stored in `start` as a negative value.
*/
export class RangePreferenceParser extends PreferenceParser {
public async handle({ request: { headers: { range }}}: { request: HttpRequest }): Promise<RepresentationPreferences> {
if (!range) {
return {};
}
const [ unit, rangeTail ] = range.split('=').map((entry): string => entry.trim());
if (unit.length === 0) {
throw new BadRequestHttpError(`Missing unit value from range header ${range}`);
}
if (!rangeTail) {
throw new BadRequestHttpError(`Invalid range header format ${range}`);
}
const ranges = rangeTail.split(',').map((entry): string => entry.trim());
const parts: { start: number; end?: number }[] = [];
for (const rangeEntry of ranges) {
const [ start, end ] = rangeEntry.split('-').map((entry): string => entry.trim());
// This can actually be undefined if the split results in less than 2 elements
if (typeof end !== 'string') {
throw new BadRequestHttpError(`Invalid range header format ${range}`);
}
if (start.length === 0) {
if (end.length === 0) {
throw new BadRequestHttpError(`Invalid range header format ${range}`);
}
parts.push({ start: -Number.parseInt(end, 10) });
} else {
const part: typeof parts[number] = { start: Number.parseInt(start, 10) };
if (end.length > 0) {
part.end = Number.parseInt(end, 10);
}
parts.push(part);
}
}
return { range: { unit, parts }};
}
}

View File

@@ -0,0 +1,32 @@
import { InternalServerError } from '../../../util/errors/InternalServerError';
import { UnionHandler } from '../../../util/handlers/UnionHandler';
import type { RepresentationPreferences } from '../../representation/RepresentationPreferences';
import type { PreferenceParser } from './PreferenceParser';
/**
* Combines the results of multiple {@link PreferenceParser}s.
* Will throw an error if multiple parsers return a range as these can't logically be combined.
*/
export class UnionPreferenceParser extends UnionHandler<PreferenceParser> {
public constructor(parsers: PreferenceParser[]) {
super(parsers, false, false);
}
protected async combine(results: RepresentationPreferences[]): Promise<RepresentationPreferences> {
const rangeCount = results.filter((result): boolean => Boolean(result.range)).length;
if (rangeCount > 1) {
throw new InternalServerError('Found multiple range values. This implies a misconfiguration.');
}
return results.reduce<RepresentationPreferences>((acc, val): RepresentationPreferences => {
for (const key of Object.keys(val) as (keyof RepresentationPreferences)[]) {
if (key === 'range') {
acc[key] = val[key];
} else {
acc[key] = { ...acc[key], ...val[key] };
}
}
return acc;
}, {});
}
}

View File

@@ -0,0 +1,50 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpResponse } from '../../../server/HttpResponse';
import { addHeader } from '../../../util/HeaderUtil';
import { termToInt } from '../../../util/QuadUtil';
import { POSIX, SOLID_HTTP } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';
/**
* Generates the necessary `content-range` header if there is range metadata.
* If the start or end is unknown, a `*` will be used instead.
* According to the RFC, this is incorrect,
* but is all we can do as long as we don't know the full length of the representation in advance.
* For the same reason, the total length of the representation will always be `*`.
*
* This class also adds the content-length header.
* This will contain either the full size for standard requests,
* or the size of the slice for range requests.
*/
export class RangeMetadataWriter extends MetadataWriter {
protected readonly logger = getLoggerFor(this);
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
const size = termToInt(input.metadata.get(POSIX.terms.size));
const unit = input.metadata.get(SOLID_HTTP.terms.unit)?.value;
if (!unit) {
if (typeof size === 'number') {
addHeader(input.response, 'Content-Length', `${size}`);
}
return;
}
let start = termToInt(input.metadata.get(SOLID_HTTP.terms.start));
if (typeof start === 'number' && start < 0 && typeof size === 'number') {
start = size + start;
}
let end = termToInt(input.metadata.get(SOLID_HTTP.terms.end));
if (typeof end !== 'number' && typeof size === 'number') {
end = size - 1;
}
const rangeHeader = `${unit} ${start ?? '*'}-${end ?? '*'}/${size ?? '*'}`;
addHeader(input.response, 'Content-Range', rangeHeader);
if (typeof start === 'number' && typeof end === 'number') {
addHeader(input.response, 'Content-Length', `${end - start + 1}`);
} else {
this.logger.warn(`Generating invalid content-range header due to missing size information: ${rangeHeader}`);
}
}
}

View File

@@ -1,10 +1,12 @@
import type { Readable } from 'stream';
import type { Guarded } from '../../../util/GuardedStream';
import { SOLID_HTTP } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { ResponseDescription } from './ResponseDescription';
/**
* Corresponds to a 200 response, containing relevant metadata and potentially data.
* Corresponds to a 200 or 206 response, containing relevant metadata and potentially data.
* A 206 will be returned if range metadata is found in the metadata object.
*/
export class OkResponseDescription extends ResponseDescription {
/**
@@ -12,6 +14,6 @@ export class OkResponseDescription extends ResponseDescription {
* @param data - Potential data. @ignored
*/
public constructor(metadata: RepresentationMetadata, data?: Guarded<Readable>) {
super(200, metadata, data);
super(metadata.has(SOLID_HTTP.terms.unit) ? 206 : 200, metadata, data);
}
}

View File

@@ -31,4 +31,6 @@ export interface RepresentationPreferences {
datetime?: ValuePreferences;
encoding?: ValuePreferences;
language?: ValuePreferences;
// `start` can be negative and implies the last X of a stream
range?: { unit: string; parts: { start: number; end?: number }[] };
}