feat: Add support for range headers

This commit is contained in:
Joachim Van Herwegen 2023-09-29 16:32:07 +02:00
parent db66e3df75
commit 3e9adef4cf
25 changed files with 619 additions and 5 deletions

View File

@ -24,6 +24,7 @@
"NotificationChannelType",
"PermissionMap",
"Promise",
"Readable",
"Readonly",
"RegExp",
"Server",
@ -31,6 +32,8 @@
"Shorthand",
"Template",
"TemplateEngine",
"Transform",
"TransformOptions",
"ValuePreferencesArg",
"VariableBindings",
"UnionHandler",

View File

@ -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

View File

@ -3,7 +3,11 @@
"@graph": [
{
"@id": "urn:solid-server:default:PreferenceParser",
"@type": "AcceptPreferenceParser"
"@type": "UnionPreferenceParser",
"parsers": [
{ "@type": "AcceptPreferenceParser" },
{ "@type": "RangePreferenceParser" }
]
}
]
}

View File

@ -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" }
]

View 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"
}
]
}

View File

@ -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" }
},
{

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,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 ?? '*'}/*`);
}
}

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 }[] };
}

View File

@ -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';

View 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
View 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();
}
}
}

View File

@ -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:',

View 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);
}
}

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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,

View 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({ });
});
});

View 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);
});
});

View 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');
});
});

View File

@ -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 ],