mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for range headers
This commit is contained in:
parent
db66e3df75
commit
3e9adef4cf
@ -24,6 +24,7 @@
|
||||
"NotificationChannelType",
|
||||
"PermissionMap",
|
||||
"Promise",
|
||||
"Readable",
|
||||
"Readonly",
|
||||
"RegExp",
|
||||
"Server",
|
||||
@ -31,6 +32,8 @@
|
||||
"Shorthand",
|
||||
"Template",
|
||||
"TemplateEngine",
|
||||
"Transform",
|
||||
"TransformOptions",
|
||||
"ValuePreferencesArg",
|
||||
"VariableBindings",
|
||||
"UnionHandler",
|
||||
|
@ -10,6 +10,7 @@ module.exports = {
|
||||
ignorePatterns: [ '*.js' ],
|
||||
globals: {
|
||||
AsyncIterable: 'readonly',
|
||||
BufferEncoding: 'readonly',
|
||||
NodeJS: 'readonly',
|
||||
RequestInit: 'readonly',
|
||||
},
|
||||
@ -71,11 +72,15 @@ module.exports = {
|
||||
// Already checked by @typescript-eslint/no-unused-vars
|
||||
'no-unused-vars': 'off',
|
||||
'padding-line-between-statements': 'off',
|
||||
// Forcing destructuring on existing variables causes clunky code
|
||||
'prefer-destructuring': 'off',
|
||||
'prefer-named-capture-group': 'off',
|
||||
// Already generated by TypeScript
|
||||
strict: 'off',
|
||||
'tsdoc/syntax': 'error',
|
||||
'unicorn/catch-error-name': 'off',
|
||||
// Can cause some clunky situations if it forces us to assign to an existing variable
|
||||
'unicorn/consistent-destructuring': 'off',
|
||||
'unicorn/import-index': 'off',
|
||||
'unicorn/import-style': 'off',
|
||||
// The next 2 some functional programming paradigms
|
||||
|
@ -3,7 +3,11 @@
|
||||
"@graph": [
|
||||
{
|
||||
"@id": "urn:solid-server:default:PreferenceParser",
|
||||
"@type": "AcceptPreferenceParser"
|
||||
"@type": "UnionPreferenceParser",
|
||||
"parsers": [
|
||||
{ "@type": "AcceptPreferenceParser" },
|
||||
{ "@type": "RangePreferenceParser" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -7,6 +7,7 @@
|
||||
"css:config/ldp/metadata-writer/writers/link-rel-metadata.json",
|
||||
"css:config/ldp/metadata-writer/writers/mapped.json",
|
||||
"css:config/ldp/metadata-writer/writers/modified.json",
|
||||
"css:config/ldp/metadata-writer/writers/range.json",
|
||||
"css:config/ldp/metadata-writer/writers/storage-description.json",
|
||||
"css:config/ldp/metadata-writer/writers/www-auth.json"
|
||||
],
|
||||
@ -22,6 +23,7 @@
|
||||
{ "@id": "urn:solid-server:default:MetadataWriter_LinkRelMetadata" },
|
||||
{ "@id": "urn:solid-server:default:MetadataWriter_Mapped" },
|
||||
{ "@id": "urn:solid-server:default:MetadataWriter_Modified" },
|
||||
{ "@id": "urn:solid-server:default:MetadataWriter_Range" },
|
||||
{ "@id": "urn:solid-server:default:MetadataWriter_StorageDescription" },
|
||||
{ "@id": "urn:solid-server:default:MetadataWriter_WwwAuth" }
|
||||
]
|
||||
|
10
config/ldp/metadata-writer/writers/range.json
Normal file
10
config/ldp/metadata-writer/writers/range.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Adds the Content-Range header if necessary.",
|
||||
"@id": "urn:solid-server:default:MetadataWriter_Range",
|
||||
"@type": "RangeMetadataWriter"
|
||||
}
|
||||
]
|
||||
}
|
@ -16,6 +16,12 @@
|
||||
"comment": "Sets up a stack of utility stores used by most instances.",
|
||||
"@id": "urn:solid-server:default:ResourceStore",
|
||||
"@type": "MonitoringStore",
|
||||
"source": { "@id": "urn:solid-server:default:ResourceStore_BinarySlice" }
|
||||
},
|
||||
{
|
||||
"comment": "Slices part of binary streams based on the range preferences.",
|
||||
"@id": "urn:solid-server:default:ResourceStore_BinarySlice",
|
||||
"@type": "BinarySliceResourceStore",
|
||||
"source": { "@id": "urn:solid-server:default:ResourceStore_Index" }
|
||||
},
|
||||
{
|
||||
|
@ -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[];
|
||||
}[] = [
|
||||
|
48
src/http/input/preferences/RangePreferenceParser.ts
Normal file
48
src/http/input/preferences/RangePreferenceParser.ts
Normal 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 }};
|
||||
}
|
||||
}
|
32
src/http/input/preferences/UnionPreferenceParser.ts
Normal file
32
src/http/input/preferences/UnionPreferenceParser.ts
Normal 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;
|
||||
}, {});
|
||||
}
|
||||
}
|
25
src/http/output/metadata/RangeMetadataWriter.ts
Normal file
25
src/http/output/metadata/RangeMetadataWriter.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { HttpResponse } from '../../../server/HttpResponse';
|
||||
import { addHeader } from '../../../util/HeaderUtil';
|
||||
import { 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 `*`.
|
||||
*/
|
||||
export class RangeMetadataWriter extends MetadataWriter {
|
||||
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
|
||||
const unit = input.metadata.get(SOLID_HTTP.terms.unit);
|
||||
if (!unit) {
|
||||
return;
|
||||
}
|
||||
const start = input.metadata.get(SOLID_HTTP.terms.start);
|
||||
const end = input.metadata.get(SOLID_HTTP.terms.end);
|
||||
|
||||
addHeader(input.response, 'Content-Range', `${unit.value} ${start?.value ?? '*'}-${end?.value ?? '*'}/*`);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 }[] };
|
||||
}
|
||||
|
@ -77,6 +77,8 @@ export * from './http/input/metadata/SlugParser';
|
||||
// HTTP/Input/Preferences
|
||||
export * from './http/input/preferences/AcceptPreferenceParser';
|
||||
export * from './http/input/preferences/PreferenceParser';
|
||||
export * from './http/input/preferences/RangePreferenceParser';
|
||||
export * from './http/input/preferences/UnionPreferenceParser';
|
||||
|
||||
// HTTP/Input
|
||||
export * from './http/input/BasicRequestParser';
|
||||
@ -106,6 +108,7 @@ export * from './http/output/metadata/LinkRelMetadataWriter';
|
||||
export * from './http/output/metadata/MappedMetadataWriter';
|
||||
export * from './http/output/metadata/MetadataWriter';
|
||||
export * from './http/output/metadata/ModifiedMetadataWriter';
|
||||
export * from './http/output/metadata/RangeMetadataWriter';
|
||||
export * from './http/output/metadata/StorageDescriptionAdvertiser';
|
||||
export * from './http/output/metadata/WacAllowMetadataWriter';
|
||||
export * from './http/output/metadata/WwwAuthMetadataWriter';
|
||||
@ -443,6 +446,7 @@ export * from './storage/validators/QuotaValidator';
|
||||
export * from './storage/AtomicResourceStore';
|
||||
export * from './storage/BaseResourceStore';
|
||||
export * from './storage/BasicConditions';
|
||||
export * from './storage/BinarySliceResourceStore';
|
||||
export * from './storage/CachedResourceSet';
|
||||
export * from './storage/Conditions';
|
||||
export * from './storage/DataAccessorBasedStore';
|
||||
@ -472,6 +476,7 @@ export * from './util/errors/NotFoundHttpError';
|
||||
export * from './util/errors/NotImplementedHttpError';
|
||||
export * from './util/errors/OAuthHttpError';
|
||||
export * from './util/errors/PreconditionFailedHttpError';
|
||||
export * from './util/errors/RangeNotSatisfiedHttpError';
|
||||
export * from './util/errors/RedirectHttpError';
|
||||
export * from './util/errors/SystemError';
|
||||
export * from './util/errors/UnauthorizedHttpError';
|
||||
@ -542,6 +547,7 @@ export * from './util/PromiseUtil';
|
||||
export * from './util/QuadUtil';
|
||||
export * from './util/RecordObject';
|
||||
export * from './util/ResourceUtil';
|
||||
export * from './util/SliceStream';
|
||||
export * from './util/StreamUtil';
|
||||
export * from './util/StringUtil';
|
||||
export * from './util/TermUtil';
|
||||
|
66
src/storage/BinarySliceResourceStore.ts
Normal file
66
src/storage/BinarySliceResourceStore.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { Representation } from '../http/representation/Representation';
|
||||
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
|
||||
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import { InternalServerError } from '../util/errors/InternalServerError';
|
||||
import { RangeNotSatisfiedHttpError } from '../util/errors/RangeNotSatisfiedHttpError';
|
||||
import { guardStream } from '../util/GuardedStream';
|
||||
import { SliceStream } from '../util/SliceStream';
|
||||
import { toLiteral } from '../util/TermUtil';
|
||||
import { SOLID_HTTP, XSD } from '../util/Vocabularies';
|
||||
import type { Conditions } from './Conditions';
|
||||
import { PassthroughStore } from './PassthroughStore';
|
||||
import type { ResourceStore } from './ResourceStore';
|
||||
|
||||
/**
|
||||
* Resource store that slices the data stream if there are range preferences.
|
||||
* Only works for `bytes` range preferences on binary data streams.
|
||||
* Does not support multipart range requests.
|
||||
*
|
||||
* If the slice happens, unit/start/end values will be written to the metadata to indicate such.
|
||||
* The values are dependent on the preferences we got as an input,
|
||||
* as we don't know the actual size of the data stream.
|
||||
*/
|
||||
export class BinarySliceResourceStore<T extends ResourceStore = ResourceStore> extends PassthroughStore<T> {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
|
||||
conditions?: Conditions): Promise<Representation> {
|
||||
const result = await this.source.getRepresentation(identifier, preferences, conditions);
|
||||
|
||||
if (!preferences.range || preferences.range.unit !== 'bytes' || preferences.range.parts.length === 0) {
|
||||
return result;
|
||||
}
|
||||
if (result.metadata.has(SOLID_HTTP.unit)) {
|
||||
this.logger.debug('Not slicing stream that has already been sliced.');
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result.binary) {
|
||||
throw new InternalServerError('Trying to slice a non-binary stream.');
|
||||
}
|
||||
if (preferences.range.parts.length > 1) {
|
||||
throw new RangeNotSatisfiedHttpError('Multipart range requests are not supported.');
|
||||
}
|
||||
|
||||
const [{ start, end }] = preferences.range.parts;
|
||||
result.metadata.set(SOLID_HTTP.terms.unit, preferences.range.unit);
|
||||
result.metadata.set(SOLID_HTTP.terms.start, toLiteral(start, XSD.terms.integer));
|
||||
if (typeof end === 'number') {
|
||||
result.metadata.set(SOLID_HTTP.terms.end, toLiteral(end, XSD.terms.integer));
|
||||
}
|
||||
|
||||
try {
|
||||
// The reason we don't determine the object mode based on the object mode of the parent stream
|
||||
// is that `guardedStreamFrom` does not create object streams when inputting streams/buffers.
|
||||
// Something to potentially update in the future.
|
||||
result.data = guardStream(new SliceStream(result.data, { start, end, objectMode: false }));
|
||||
} catch (error: unknown) {
|
||||
// Creating the slice stream can throw an error if some of the parameters are unacceptable.
|
||||
// Need to make sure the stream is closed in that case.
|
||||
result.data.destroy();
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
87
src/util/SliceStream.ts
Normal file
87
src/util/SliceStream.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { Readable, TransformCallback, TransformOptions } from 'stream';
|
||||
import { Transform } from 'stream';
|
||||
import { RangeNotSatisfiedHttpError } from './errors/RangeNotSatisfiedHttpError';
|
||||
import { pipeSafely } from './StreamUtil';
|
||||
|
||||
/**
|
||||
* A stream that slices a part out of another stream.
|
||||
* `start` and `end` are inclusive.
|
||||
* If `end` is not defined it is until the end of the stream.
|
||||
* Does not support negative `start` values which would indicate slicing the end of the stream off,
|
||||
* since we don't know the length of the input stream.
|
||||
*
|
||||
* Both object and non-object streams are supported.
|
||||
* This needs to be explicitly specified,
|
||||
* as the class makes no assumptions based on the object mode of the source stream.
|
||||
*/
|
||||
export class SliceStream extends Transform {
|
||||
protected readonly source: Readable;
|
||||
protected remainingSkip: number;
|
||||
protected remainingRead: number;
|
||||
|
||||
public constructor(source: Readable, options: TransformOptions & { start: number; end?: number }) {
|
||||
super(options);
|
||||
const end = options.end ?? Number.POSITIVE_INFINITY;
|
||||
if (options.start < 0) {
|
||||
throw new RangeNotSatisfiedHttpError('Slicing data at the end of a stream is not supported.');
|
||||
}
|
||||
if (options.start >= end) {
|
||||
throw new RangeNotSatisfiedHttpError('Range start should be less than end.');
|
||||
}
|
||||
this.remainingSkip = options.start;
|
||||
// End value is inclusive
|
||||
this.remainingRead = end - options.start + 1;
|
||||
|
||||
this.source = source;
|
||||
pipeSafely(source, this);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void {
|
||||
this.source.pause();
|
||||
if (this.writableObjectMode) {
|
||||
this.objectSlice(chunk);
|
||||
} else {
|
||||
this.binarySlice(chunk);
|
||||
}
|
||||
// eslint-disable-next-line callback-return
|
||||
callback();
|
||||
this.source.resume();
|
||||
}
|
||||
|
||||
protected binarySlice(chunk: Buffer): void {
|
||||
let length = chunk.length;
|
||||
if (this.remainingSkip > 0) {
|
||||
chunk = chunk.slice(this.remainingSkip);
|
||||
this.remainingSkip -= length - chunk.length;
|
||||
length = chunk.length;
|
||||
}
|
||||
if (length > 0 && this.remainingSkip <= 0) {
|
||||
chunk = chunk.slice(0, this.remainingRead);
|
||||
this.push(chunk);
|
||||
this.remainingRead -= length;
|
||||
this.checkEnd();
|
||||
}
|
||||
}
|
||||
|
||||
protected objectSlice(chunk: unknown): void {
|
||||
if (this.remainingSkip > 0) {
|
||||
this.remainingSkip -= 1;
|
||||
} else {
|
||||
this.remainingRead -= 1;
|
||||
this.push(chunk);
|
||||
this.checkEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop piping the source stream and close everything once the slice is finished.
|
||||
*/
|
||||
protected checkEnd(): void {
|
||||
if (this.remainingRead <= 0) {
|
||||
this.source.unpipe();
|
||||
this.end();
|
||||
this.source.destroy();
|
||||
}
|
||||
}
|
||||
}
|
@ -258,8 +258,12 @@ export const SOLID_ERROR = createVocabulary('urn:npm:solid:community-server:erro
|
||||
);
|
||||
|
||||
export const SOLID_HTTP = createVocabulary('urn:npm:solid:community-server:http:',
|
||||
// Unit, start, and end are used for range headers
|
||||
'end',
|
||||
'location',
|
||||
'start',
|
||||
'slug',
|
||||
'unit',
|
||||
);
|
||||
|
||||
export const SOLID_META = createVocabulary('urn:npm:solid:community-server:meta:',
|
||||
|
19
src/util/errors/RangeNotSatisfiedHttpError.ts
Normal file
19
src/util/errors/RangeNotSatisfiedHttpError.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { HttpErrorOptions } from './HttpError';
|
||||
import { generateHttpErrorClass } from './HttpError';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const BaseHttpError = generateHttpErrorClass(416, 'RangeNotSatisfiedHttpError');
|
||||
|
||||
/**
|
||||
* An error thrown when the requested range is not supported.
|
||||
*/
|
||||
export class RangeNotSatisfiedHttpError extends BaseHttpError {
|
||||
/**
|
||||
* Default message is 'The requested range is not supported.'.
|
||||
* @param message - Optional, more specific, message.
|
||||
* @param options - Optional error options.
|
||||
*/
|
||||
public constructor(message?: string, options?: HttpErrorOptions) {
|
||||
super(message ?? 'The requested range is not supported.', options);
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ import { createReadStream } from 'fs';
|
||||
import fetch from 'cross-fetch';
|
||||
import type { Quad } from 'n3';
|
||||
import { DataFactory, Parser, Store } from 'n3';
|
||||
import { joinFilePath, PIM, RDF } from '../../src/';
|
||||
import { joinFilePath, joinUrl, PIM, RDF } from '../../src/';
|
||||
import type { App } from '../../src/';
|
||||
import { LDP } from '../../src/util/Vocabularies';
|
||||
import {
|
||||
@ -726,4 +726,27 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC
|
||||
// DELETE
|
||||
await deleteResource(resourceUrl);
|
||||
});
|
||||
|
||||
it('supports range requests.', async(): Promise<void> => {
|
||||
const resourceUrl = joinUrl(baseUrl, 'range');
|
||||
await putResource(resourceUrl, { contentType: 'text/plain', body: '0123456789' });
|
||||
|
||||
let response = await fetch(resourceUrl, { headers: { range: 'bytes=0-5' }});
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get('content-range')).toBe('bytes 0-5/*');
|
||||
await expect(response.text()).resolves.toBe('012345');
|
||||
|
||||
response = await fetch(resourceUrl, { headers: { range: 'bytes=5-' }});
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get('content-range')).toBe('bytes 5-*/*');
|
||||
await expect(response.text()).resolves.toBe('56789');
|
||||
|
||||
response = await fetch(resourceUrl, { headers: { range: 'bytes=5-15' }});
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get('content-range')).toBe('bytes 5-15/*');
|
||||
await expect(response.text()).resolves.toBe('56789');
|
||||
|
||||
response = await fetch(resourceUrl, { headers: { range: 'bytes=-5' }});
|
||||
expect(response.status).toBe(416);
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,40 @@
|
||||
import { RangePreferenceParser } from '../../../../../src/http/input/preferences/RangePreferenceParser';
|
||||
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
|
||||
|
||||
describe('A RangePreferenceParser', (): void => {
|
||||
const parser = new RangePreferenceParser();
|
||||
|
||||
it('parses range headers.', async(): Promise<void> => {
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes=5-10' }}} as any))
|
||||
.resolves.toEqual({ range: { unit: 'bytes', parts: [{ start: 5, end: 10 }]}});
|
||||
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes=5-' }}} as any))
|
||||
.resolves.toEqual({ range: { unit: 'bytes', parts: [{ start: 5 }]}});
|
||||
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes=-5' }}} as any))
|
||||
.resolves.toEqual({ range: { unit: 'bytes', parts: [{ start: -5 }]}});
|
||||
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes=5-10, 11-20, 21-99' }}} as any))
|
||||
.resolves.toEqual({ range: { unit: 'bytes',
|
||||
parts: [{ start: 5, end: 10 }, { start: 11, end: 20 }, { start: 21, end: 99 }]}});
|
||||
});
|
||||
|
||||
it('returns an empty object if there is no header.', async(): Promise<void> => {
|
||||
await expect(parser.handle({ request: { headers: {}}} as any)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('rejects invalid range headers.', async(): Promise<void> => {
|
||||
await expect(parser.handle({ request: { headers: { range: '=5-10' }}} as any))
|
||||
.rejects.toThrow(BadRequestHttpError);
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes' }}} as any))
|
||||
.rejects.toThrow(BadRequestHttpError);
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes=' }}} as any))
|
||||
.rejects.toThrow(BadRequestHttpError);
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes=-' }}} as any))
|
||||
.rejects.toThrow(BadRequestHttpError);
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes=5' }}} as any))
|
||||
.rejects.toThrow(BadRequestHttpError);
|
||||
await expect(parser.handle({ request: { headers: { range: 'bytes=5-10, 99' }}} as any))
|
||||
.rejects.toThrow(BadRequestHttpError);
|
||||
});
|
||||
});
|
@ -0,0 +1,54 @@
|
||||
import { PreferenceParser } from '../../../../../src/http/input/preferences/PreferenceParser';
|
||||
import { UnionPreferenceParser } from '../../../../../src/http/input/preferences/UnionPreferenceParser';
|
||||
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
|
||||
|
||||
describe('A UnionPreferenceParser', (): void => {
|
||||
let parsers: jest.Mocked<PreferenceParser>[];
|
||||
let parser: UnionPreferenceParser;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
parsers = [
|
||||
{
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn().mockResolvedValue({}),
|
||||
} satisfies Partial<PreferenceParser> as any,
|
||||
{
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn().mockResolvedValue({}),
|
||||
} satisfies Partial<PreferenceParser> as any,
|
||||
];
|
||||
|
||||
parser = new UnionPreferenceParser(parsers);
|
||||
});
|
||||
|
||||
it('combines the outputs.', async(): Promise<void> => {
|
||||
parsers[0].handle.mockResolvedValue({
|
||||
type: { 'text/turtle': 1 },
|
||||
range: { unit: 'bytes', parts: [{ start: 3, end: 5 }]},
|
||||
});
|
||||
parsers[1].handle.mockResolvedValue({
|
||||
type: { 'text/plain': 0.9 },
|
||||
language: { nl: 0.8 },
|
||||
});
|
||||
|
||||
await expect(parser.handle({} as any)).resolves.toEqual({
|
||||
type: { 'text/turtle': 1, 'text/plain': 0.9 },
|
||||
language: { nl: 0.8 },
|
||||
range: { unit: 'bytes', parts: [{ start: 3, end: 5 }]},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if multiple parsers return a range.', async(): Promise<void> => {
|
||||
parsers[0].handle.mockResolvedValue({
|
||||
type: { 'text/turtle': 1 },
|
||||
range: { unit: 'bytes', parts: [{ start: 3, end: 5 }]},
|
||||
});
|
||||
parsers[1].handle.mockResolvedValue({
|
||||
type: { 'text/plain': 0.9 },
|
||||
language: { nl: 0.8 },
|
||||
range: { unit: 'bytes', parts: [{ start: 3, end: 5 }]},
|
||||
});
|
||||
|
||||
await expect(parser.handle({} as any)).rejects.toThrow(InternalServerError);
|
||||
});
|
||||
});
|
@ -8,6 +8,7 @@ import { BasicConditions } from '../../../../src/storage/BasicConditions';
|
||||
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
|
||||
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
|
||||
import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError';
|
||||
import { SOLID_HTTP } from '../../../../src/util/Vocabularies';
|
||||
|
||||
describe('A GetOperationHandler', (): void => {
|
||||
let operation: Operation;
|
||||
@ -45,6 +46,18 @@ describe('A GetOperationHandler', (): void => {
|
||||
expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions);
|
||||
});
|
||||
|
||||
it('returns 206 if the result is a partial stream.', async(): Promise<void> => {
|
||||
metadata.set(SOLID_HTTP.terms.unit, 'bytes');
|
||||
metadata.set(SOLID_HTTP.terms.start, '5');
|
||||
metadata.set(SOLID_HTTP.terms.end, '7');
|
||||
const result = await handler.handle({ operation });
|
||||
expect(result.statusCode).toBe(206);
|
||||
expect(result.metadata).toBe(metadata);
|
||||
expect(result.data).toBe(data);
|
||||
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
|
||||
expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions);
|
||||
});
|
||||
|
||||
it('returns a 304 if the conditions do not match.', async(): Promise<void> => {
|
||||
operation.conditions = {
|
||||
matchesMetadata: (): boolean => false,
|
||||
|
40
test/unit/http/output/metadata/RangeMetadataWriter.test.ts
Normal file
40
test/unit/http/output/metadata/RangeMetadataWriter.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { createResponse } from 'node-mocks-http';
|
||||
import { RangeMetadataWriter } from '../../../../../src/http/output/metadata/RangeMetadataWriter';
|
||||
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
|
||||
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
|
||||
import { SOLID_HTTP } from '../../../../../src/util/Vocabularies';
|
||||
|
||||
describe('RangeMetadataWriter', (): void => {
|
||||
let metadata: RepresentationMetadata;
|
||||
let response: HttpResponse;
|
||||
let writer: RangeMetadataWriter;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
metadata = new RepresentationMetadata();
|
||||
response = createResponse();
|
||||
writer = new RangeMetadataWriter();
|
||||
});
|
||||
|
||||
it('adds the content-range header.', async(): Promise<void> => {
|
||||
metadata.set(SOLID_HTTP.terms.unit, 'bytes');
|
||||
metadata.set(SOLID_HTTP.terms.start, '1');
|
||||
metadata.set(SOLID_HTTP.terms.end, '5');
|
||||
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({
|
||||
'content-range': 'bytes 1-5/*',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses * if the value is unknown.', async(): Promise<void> => {
|
||||
metadata.set(SOLID_HTTP.terms.unit, 'bytes');
|
||||
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({
|
||||
'content-range': 'bytes *-*/*',
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing if there is no range metadata.', async(): Promise<void> => {
|
||||
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({ });
|
||||
});
|
||||
});
|
80
test/unit/storage/BinarySliceResourceStore.test.ts
Normal file
80
test/unit/storage/BinarySliceResourceStore.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../src/http/representation/Representation';
|
||||
import { BinarySliceResourceStore } from '../../../src/storage/BinarySliceResourceStore';
|
||||
import { ResourceStore } from '../../../src/storage/ResourceStore';
|
||||
import { InternalServerError } from '../../../src/util/errors/InternalServerError';
|
||||
import { RangeNotSatisfiedHttpError } from '../../../src/util/errors/RangeNotSatisfiedHttpError';
|
||||
import { readableToString } from '../../../src/util/StreamUtil';
|
||||
import { SOLID_HTTP } from '../../../src/util/Vocabularies';
|
||||
|
||||
describe('A BinarySliceResourceStore', (): void => {
|
||||
const identifier = { path: 'path' };
|
||||
let representation: Representation;
|
||||
let source: jest.Mocked<ResourceStore>;
|
||||
let store: BinarySliceResourceStore;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
representation = new BasicRepresentation('0123456789', 'text/plain');
|
||||
|
||||
source = {
|
||||
getRepresentation: jest.fn().mockResolvedValue(representation),
|
||||
} satisfies Partial<ResourceStore> as any;
|
||||
|
||||
store = new BinarySliceResourceStore(source);
|
||||
});
|
||||
|
||||
it('slices the data stream and stores the metadata.', async(): Promise<void> => {
|
||||
const result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: 1, end: 4 }]}});
|
||||
await expect(readableToString(result.data)).resolves.toBe('1234');
|
||||
expect(result.metadata.get(SOLID_HTTP.terms.unit)?.value).toBe('bytes');
|
||||
expect(result.metadata.get(SOLID_HTTP.terms.start)?.value).toBe('1');
|
||||
expect(result.metadata.get(SOLID_HTTP.terms.end)?.value).toBe('4');
|
||||
});
|
||||
|
||||
it('does not add end metadata if there is none.', async(): Promise<void> => {
|
||||
const result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: 5 }]}});
|
||||
await expect(readableToString(result.data)).resolves.toBe('56789');
|
||||
expect(result.metadata.get(SOLID_HTTP.terms.unit)?.value).toBe('bytes');
|
||||
expect(result.metadata.get(SOLID_HTTP.terms.start)?.value).toBe('5');
|
||||
expect(result.metadata.get(SOLID_HTTP.terms.end)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the original data if there is no valid range request.', async(): Promise<void> => {
|
||||
let result = await store.getRepresentation(identifier, {});
|
||||
await expect(readableToString(result.data)).resolves.toBe('0123456789');
|
||||
|
||||
source.getRepresentation.mockResolvedValue(new BasicRepresentation('0123456789', 'text/plain'));
|
||||
result = await store.getRepresentation(identifier, { range: { unit: 'triples', parts: []}});
|
||||
await expect(readableToString(result.data)).resolves.toBe('0123456789');
|
||||
|
||||
source.getRepresentation.mockResolvedValue(new BasicRepresentation('0123456789', 'text/plain'));
|
||||
result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: []}});
|
||||
await expect(readableToString(result.data)).resolves.toBe('0123456789');
|
||||
});
|
||||
|
||||
it('returns the original data if there already is slice metadata.', async(): Promise<void> => {
|
||||
representation.metadata.set(SOLID_HTTP.terms.unit, 'triples');
|
||||
|
||||
const result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: 5 }]}});
|
||||
await expect(readableToString(result.data)).resolves.toBe('0123456789');
|
||||
});
|
||||
|
||||
it('only supports binary streams.', async(): Promise<void> => {
|
||||
representation.binary = false;
|
||||
await expect(store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: 5 }]}}))
|
||||
.rejects.toThrow(InternalServerError);
|
||||
});
|
||||
|
||||
it('does not support multipart ranges.', async(): Promise<void> => {
|
||||
await expect(store.getRepresentation(identifier,
|
||||
{ range: { unit: 'bytes', parts: [{ start: 5, end: 6 }, { start: 7, end: 8 }]}}))
|
||||
.rejects.toThrow(RangeNotSatisfiedHttpError);
|
||||
});
|
||||
|
||||
it('closes the source stream if there was an error creating the SliceStream.', async(): Promise<void> => {
|
||||
representation.data.destroy = jest.fn();
|
||||
await expect(store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: -5 }]}}))
|
||||
.rejects.toThrow(RangeNotSatisfiedHttpError);
|
||||
expect(representation.data.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
41
test/unit/util/SliceStream.test.ts
Normal file
41
test/unit/util/SliceStream.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Readable } from 'stream';
|
||||
import { RangeNotSatisfiedHttpError } from '../../../src/util/errors/RangeNotSatisfiedHttpError';
|
||||
import { SliceStream } from '../../../src/util/SliceStream';
|
||||
import { readableToString } from '../../../src/util/StreamUtil';
|
||||
|
||||
describe('A SliceStream', (): void => {
|
||||
it('does not support suffix slicing.', async(): Promise<void> => {
|
||||
expect((): unknown => new SliceStream(Readable.from('0123456789'), { start: -5 }))
|
||||
.toThrow(RangeNotSatisfiedHttpError);
|
||||
});
|
||||
|
||||
it('requires the end to be more than the start.', async(): Promise<void> => {
|
||||
expect((): unknown => new SliceStream(Readable.from('0123456789'), { start: 5, end: 4 }))
|
||||
.toThrow(RangeNotSatisfiedHttpError);
|
||||
expect((): unknown => new SliceStream(Readable.from('0123456789'), { start: 5, end: 5 }))
|
||||
.toThrow(RangeNotSatisfiedHttpError);
|
||||
});
|
||||
|
||||
it('can slice binary streams.', async(): Promise<void> => {
|
||||
await expect(readableToString(new SliceStream(Readable.from('0123456789', { objectMode: false }),
|
||||
{ start: 3, end: 7, objectMode: false }))).resolves.toBe('34567');
|
||||
|
||||
await expect(readableToString(new SliceStream(Readable.from('0123456789', { objectMode: false }),
|
||||
{ start: 3, objectMode: false }))).resolves.toBe('3456789');
|
||||
|
||||
await expect(readableToString(new SliceStream(Readable.from('0123456789', { objectMode: false }),
|
||||
{ start: 3, end: 20, objectMode: false }))).resolves.toBe('3456789');
|
||||
});
|
||||
|
||||
it('can slice object streams.', async(): Promise<void> => {
|
||||
const arr = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' ];
|
||||
await expect(readableToString(new SliceStream(Readable.from(arr, { objectMode: true }),
|
||||
{ start: 3, end: 7, objectMode: true }))).resolves.toBe('34567');
|
||||
|
||||
await expect(readableToString(new SliceStream(Readable.from(arr, { objectMode: true }),
|
||||
{ start: 3, objectMode: true }))).resolves.toBe('3456789');
|
||||
|
||||
await expect(readableToString(new SliceStream(Readable.from(arr, { objectMode: true }),
|
||||
{ start: 3, end: 20, objectMode: true }))).resolves.toBe('3456789');
|
||||
});
|
||||
});
|
@ -12,6 +12,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen
|
||||
import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError';
|
||||
import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError';
|
||||
import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError';
|
||||
import { RangeNotSatisfiedHttpError } from '../../../../src/util/errors/RangeNotSatisfiedHttpError';
|
||||
import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError';
|
||||
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
|
||||
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
|
||||
@ -30,6 +31,7 @@ describe('HttpError', (): void => {
|
||||
[ 'PreconditionFailedHttpError', 412, PreconditionFailedHttpError ],
|
||||
[ 'PayloadHttpError', 413, PayloadHttpError ],
|
||||
[ 'UnsupportedMediaTypeHttpError', 415, UnsupportedMediaTypeHttpError ],
|
||||
[ 'RangeNotSatisfiedHttpError', 416, RangeNotSatisfiedHttpError ],
|
||||
[ 'UnprocessableEntityHttpError', 422, UnprocessableEntityHttpError ],
|
||||
[ 'InternalServerError', 500, InternalServerError ],
|
||||
[ 'NotImplementedHttpError', 501, NotImplementedHttpError ],
|
||||
|
Loading…
x
Reference in New Issue
Block a user