diff --git a/src/storage/SimpleResourceStore.ts b/src/storage/SimpleResourceStore.ts new file mode 100644 index 000000000..b1855ed36 --- /dev/null +++ b/src/storage/SimpleResourceStore.ts @@ -0,0 +1,89 @@ +import arrayifyStream from 'arrayify-stream'; +import { BinaryRepresentation } from '../ldp/representation/BinaryRepresentation'; +import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; +import { Quad } from 'rdf-js'; +import { QuadRepresentation } from '../ldp/representation/QuadRepresentation'; +import { Readable } from 'stream'; +import { Representation } from '../ldp/representation/Representation'; +import { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; +import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { ResourceStore } from './ResourceStore'; +import streamifyArray from 'streamify-array'; +import { StreamWriter } from 'n3'; +import { UnsupportedMediaTypeHttpError } from '../util/errors/UnsupportedMediaTypeHttpError'; + +export class SimpleResourceStore implements ResourceStore { + private readonly store: { [id: string]: Quad[] } = { '': []}; + private readonly base: string; + private index = 0; + + public constructor(base: string) { + this.base = base; + } + + public async addResource(container: ResourceIdentifier, representation: Representation): Promise { + const containerPath = this.parseIdentifier(container); + const newPath = `${containerPath}/${this.index}`; + this.index += 1; + this.store[newPath] = await this.parseRepresentation(representation); + return { path: `${this.base}${newPath}` }; + } + + public async deleteResource(identifier: ResourceIdentifier): Promise { + const path = this.parseIdentifier(identifier); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.store[path]; + } + + public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences): Promise { + const path = this.parseIdentifier(identifier); + return this.generateRepresentation(this.store[path], preferences); + } + + public async modifyResource(): Promise { + throw new Error('Not supported.'); + } + + public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise { + const path = this.parseIdentifier(identifier); + this.store[path] = await this.parseRepresentation(representation); + } + + private parseIdentifier(identifier: ResourceIdentifier): string { + const path = identifier.path.slice(this.base.length); + if (!this.store[path] || !identifier.path.startsWith(this.base)) { + throw new NotFoundHttpError(); + } + return path; + } + + private async parseRepresentation(representation: Representation): Promise { + if (representation.dataType !== 'quad') { + throw new UnsupportedMediaTypeHttpError('SimpleResourceStore only supports quad representations.'); + } + return arrayifyStream(representation.data); + } + + private generateRepresentation(data: Quad[], preferences: RepresentationPreferences): Representation { + if (preferences.type && preferences.type.some((preference): boolean => preference.value.includes('text/turtle'))) { + return this.generateBinaryRepresentation(data); + } + return this.generateQuadRepresentation(data); + } + + private generateBinaryRepresentation(data: Quad[]): BinaryRepresentation { + return { + dataType: 'binary', + data: streamifyArray(data).pipe(new StreamWriter({ format: 'text/turtle' })) as unknown as Readable, + metadata: { raw: [], profiles: [], contentType: 'text/turtle' }, + }; + } + + private generateQuadRepresentation(data: Quad[]): QuadRepresentation { + return { + dataType: 'quad', + data: streamifyArray(data), + metadata: { raw: [], profiles: []}, + }; + } +} diff --git a/src/util/errors/NotFoundHttpError.ts b/src/util/errors/NotFoundHttpError.ts new file mode 100644 index 000000000..276d2c1ed --- /dev/null +++ b/src/util/errors/NotFoundHttpError.ts @@ -0,0 +1,7 @@ +import { HttpError } from './HttpError'; + +export class NotFoundHttpError extends HttpError { + public constructor(message?: string) { + super(404, 'NotFoundHttpError', message); + } +} diff --git a/test/unit/storage/SimpleResourceStore.test.ts b/test/unit/storage/SimpleResourceStore.test.ts new file mode 100644 index 000000000..2ece75f2f --- /dev/null +++ b/test/unit/storage/SimpleResourceStore.test.ts @@ -0,0 +1,98 @@ +import arrayifyStream from 'arrayify-stream'; +import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; +import { QuadRepresentation } from '../../../src/ldp/representation/QuadRepresentation'; +import { Readable } from 'stream'; +import { SimpleResourceStore } from '../../../src/storage/SimpleResourceStore'; +import streamifyArray from 'streamify-array'; +import { UnsupportedMediaTypeHttpError } from '../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { namedNode, triple } from '@rdfjs/data-model'; + +const base = 'http://test.com/'; + +describe('A SimpleResourceStore', (): void => { + let store: SimpleResourceStore; + let representation: QuadRepresentation; + const quad = triple( + namedNode('http://test.com/s'), + namedNode('http://test.com/p'), + namedNode('http://test.com/o'), + ); + + beforeEach(async(): Promise => { + store = new SimpleResourceStore(base); + + representation = { + data: streamifyArray([ quad ]), + dataType: 'quad', + metadata: null, + }; + }); + + it('errors if a resource was not found.', async(): Promise => { + await expect(store.getRepresentation({ path: `${base}wrong` }, {})).rejects.toThrow(NotFoundHttpError); + await expect(store.addResource({ path: 'http://wrong.com/wrong' }, null)).rejects.toThrow(NotFoundHttpError); + await expect(store.deleteResource({ path: 'wrong' })).rejects.toThrow(NotFoundHttpError); + await expect(store.setRepresentation({ path: 'http://wrong.com/' }, null)).rejects.toThrow(NotFoundHttpError); + }); + + it('errors when modifying resources.', async(): Promise => { + await expect(store.modifyResource()).rejects.toThrow(Error); + }); + + it('errors for wrong input data types.', async(): Promise => { + (representation as any).dataType = 'binary'; + await expect(store.addResource({ path: base }, representation)).rejects.toThrow(UnsupportedMediaTypeHttpError); + }); + + it('can write and read data.', async(): Promise => { + const identifier = await store.addResource({ path: base }, representation); + expect(identifier.path.startsWith(base)).toBeTruthy(); + const result = await store.getRepresentation(identifier, {}); + expect(result).toEqual({ + dataType: 'quad', + data: expect.any(Readable), + metadata: { + profiles: [], + raw: [], + }, + }); + await expect(arrayifyStream(result.data)).resolves.toEqualRdfQuadArray([ quad ]); + }); + + it('can read binary data.', async(): Promise => { + const identifier = await store.addResource({ path: base }, representation); + expect(identifier.path.startsWith(base)).toBeTruthy(); + const result = await store.getRepresentation(identifier, { type: [{ value: 'text/turtle', weight: 1 }]}); + expect(result).toEqual({ + dataType: 'binary', + data: expect.any(Readable), + metadata: { + profiles: [], + raw: [], + contentType: 'text/turtle', + }, + }); + await expect(arrayifyStream(result.data)).resolves.toContain( + `<${quad.subject.value}> <${quad.predicate.value}> <${quad.object.value}>`, + ); + }); + + it('can set data.', async(): Promise => { + await store.setRepresentation({ path: base }, representation); + const result = await store.getRepresentation({ path: base }, {}); + expect(result).toEqual({ + dataType: 'quad', + data: expect.any(Readable), + metadata: { + profiles: [], + raw: [], + }, + }); + await expect(arrayifyStream(result.data)).resolves.toEqualRdfQuadArray([ quad ]); + }); + + it('can delete data.', async(): Promise => { + await store.deleteResource({ path: base }); + await expect(store.getRepresentation({ path: base }, {})).rejects.toThrow(NotFoundHttpError); + }); +});