From 59deb989eccbb4368a97088d4b6fcb612a988341 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 3 Feb 2021 15:46:32 +0100 Subject: [PATCH] feat: Introduce internal storing mechanism --- src/index.ts | 6 ++ src/storage/keyvalue/JsonResourceStorage.ts | 64 +++++++++++++++ src/storage/keyvalue/KeyValueStorage.ts | 36 +++++++++ src/storage/keyvalue/MemoryMapStorage.ts | 31 +++++++ .../keyvalue/ResourceIdentifierStorage.ts | 33 ++++++++ .../keyvalue/JsonResourceStorage.test.ts | 81 +++++++++++++++++++ .../storage/keyvalue/MemoryMapStorage.test.ts | 45 +++++++++++ .../ResourceIdentifierStorage.test.ts | 37 +++++++++ 8 files changed, 333 insertions(+) create mode 100644 src/storage/keyvalue/JsonResourceStorage.ts create mode 100644 src/storage/keyvalue/KeyValueStorage.ts create mode 100644 src/storage/keyvalue/MemoryMapStorage.ts create mode 100644 src/storage/keyvalue/ResourceIdentifierStorage.ts create mode 100644 test/unit/storage/keyvalue/JsonResourceStorage.test.ts create mode 100644 test/unit/storage/keyvalue/MemoryMapStorage.test.ts create mode 100644 test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts diff --git a/src/index.ts b/src/index.ts index 5a2847f9a..c5a08e2e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -144,6 +144,12 @@ export * from './storage/conversion/RdfToQuadConverter'; export * from './storage/conversion/RepresentationConverter'; export * from './storage/conversion/TypedRepresentationConverter'; +// Storage/KeyValueStorage +export * from './storage/keyvalue/JsonResourceStorage'; +export * from './storage/keyvalue/KeyValueStorage'; +export * from './storage/keyvalue/MemoryMapStorage'; +export * from './storage/keyvalue/ResourceIdentifierStorage'; + // Storage/Mapping export * from './storage/mapping/BaseFileIdentifierMapper'; export * from './storage/mapping/ExtensionBasedMapper'; diff --git a/src/storage/keyvalue/JsonResourceStorage.ts b/src/storage/keyvalue/JsonResourceStorage.ts new file mode 100644 index 000000000..247efd49b --- /dev/null +++ b/src/storage/keyvalue/JsonResourceStorage.ts @@ -0,0 +1,64 @@ +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import { readableToString } from '../../util/StreamUtil'; +import type { ResourceStore } from '../ResourceStore'; +import type { KeyValueStorage } from './KeyValueStorage'; + +/** + * A {@link KeyValueStorage} for strings using a {@link ResourceStore} as backend. + * + * Values will be sent as data streams to the given identifiers, + * so how these are stored depend on the underlying store. + * + * All non-404 errors will be re-thrown. + */ +export class JsonResourceStorage implements KeyValueStorage { + private readonly source: ResourceStore; + + public constructor(source: ResourceStore) { + this.source = source; + } + + public async get(identifier: ResourceIdentifier): Promise { + try { + const representation = await this.source.getRepresentation(identifier, { type: { 'application/json': 1 }}); + return JSON.parse(await readableToString(representation.data)); + } catch (error: unknown) { + if (!NotFoundHttpError.isInstance(error)) { + throw error; + } + } + } + + public async has(identifier: ResourceIdentifier): Promise { + try { + const representation = await this.source.getRepresentation(identifier, { type: { 'application/json': 1 }}); + representation.data.destroy(); + return true; + } catch (error: unknown) { + if (!NotFoundHttpError.isInstance(error)) { + throw error; + } + return false; + } + } + + public async set(identifier: ResourceIdentifier, value: unknown): Promise { + const representation = new BasicRepresentation(JSON.stringify(value), identifier, 'application/json'); + await this.source.setRepresentation(identifier, representation); + return this; + } + + public async delete(identifier: ResourceIdentifier): Promise { + try { + await this.source.deleteResource(identifier); + return true; + } catch (error: unknown) { + if (!NotFoundHttpError.isInstance(error)) { + throw error; + } + return false; + } + } +} diff --git a/src/storage/keyvalue/KeyValueStorage.ts b/src/storage/keyvalue/KeyValueStorage.ts new file mode 100644 index 000000000..fb5dd0f38 --- /dev/null +++ b/src/storage/keyvalue/KeyValueStorage.ts @@ -0,0 +1,36 @@ +/** + * A simple storage solution that can be used for internal values that need to be stored. + * In general storages taking objects as keys are expected to work with different instances + * of an object with the same values. Exceptions to this expectation should be clearly documented. + */ +export interface KeyValueStorage { + /** + * Returns the value stored for the given identifier. + * `undefined` if no value is stored. + * @param identifier - Identifier to get the value for. + */ + get: (key: TKey) => Promise; + + /** + * Checks if there is a value stored for the given key. + * @param identifier - Identifier to check. + */ + has: (key: TKey) => Promise; + + /** + * Sets the value for the given key. + * @param key - Key to set/update. + * @param value - Value to store. + * + * @returns The storage. + */ + set: (key: TKey, value: TValue) => Promise; + + /** + * Deletes the value stored for the given key. + * @param key - Key to delete. + * + * @returns If there was a value to delete. + */ + delete: (key: TKey) => Promise; +} diff --git a/src/storage/keyvalue/MemoryMapStorage.ts b/src/storage/keyvalue/MemoryMapStorage.ts new file mode 100644 index 000000000..ad1bbf534 --- /dev/null +++ b/src/storage/keyvalue/MemoryMapStorage.ts @@ -0,0 +1,31 @@ +import type { KeyValueStorage } from './KeyValueStorage'; + +/** + * A {@link KeyValueStorage} which uses a JavaScript Map for internal storage. + * Warning: Uses a Map object, which internally uses `Object.is` for key equality, + * so object keys have to be the same objects. + */ +export class MemoryMapStorage implements KeyValueStorage { + private readonly data: Map; + + public constructor() { + this.data = new Map(); + } + + public async get(key: TKey): Promise { + return this.data.get(key); + } + + public async has(key: TKey): Promise { + return this.data.has(key); + } + + public async set(key: TKey, value: TValue): Promise { + this.data.set(key, value); + return this; + } + + public async delete(key: TKey): Promise { + return this.data.delete(key); + } +} diff --git a/src/storage/keyvalue/ResourceIdentifierStorage.ts b/src/storage/keyvalue/ResourceIdentifierStorage.ts new file mode 100644 index 000000000..e513bcb51 --- /dev/null +++ b/src/storage/keyvalue/ResourceIdentifierStorage.ts @@ -0,0 +1,33 @@ +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import type { KeyValueStorage } from './KeyValueStorage'; + +/** + * Wrapper class that internally converts ResourceIdentifiers to strings so Storages + * that do not check value equivalence can be used with ResourceIdentifiers. + * + * Specifically: this makes it so a Storage based on a Map object can be used with ResourceIdentifiers. + */ +export class ResourceIdentifierStorage implements KeyValueStorage { + private readonly source: KeyValueStorage; + + public constructor(source: KeyValueStorage) { + this.source = source; + } + + public async get(key: ResourceIdentifier): Promise { + return this.source.get(key.path); + } + + public async has(key: ResourceIdentifier): Promise { + return this.source.has(key.path); + } + + public async set(key: ResourceIdentifier, value: T): Promise { + await this.source.set(key.path, value); + return this; + } + + public async delete(key: ResourceIdentifier): Promise { + return this.source.delete(key.path); + } +} diff --git a/test/unit/storage/keyvalue/JsonResourceStorage.test.ts b/test/unit/storage/keyvalue/JsonResourceStorage.test.ts new file mode 100644 index 000000000..c86f858b9 --- /dev/null +++ b/test/unit/storage/keyvalue/JsonResourceStorage.test.ts @@ -0,0 +1,81 @@ +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import { JsonResourceStorage } from '../../../../src/storage/keyvalue/JsonResourceStorage'; +import type { ResourceStore } from '../../../../src/storage/ResourceStore'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { readableToString } from '../../../../src/util/StreamUtil'; + +describe('A JsonResourceStorage', (): void => { + const identifier1: ResourceIdentifier = { path: 'http://test.com/foo' }; + const identifier2: ResourceIdentifier = { path: 'http://test.com/bar' }; + let store: ResourceStore; + let storage: JsonResourceStorage; + + beforeEach(async(): Promise => { + const data: Record = { }; + store = { + async getRepresentation(identifier: ResourceIdentifier): Promise { + if (!data[identifier.path]) { + throw new NotFoundHttpError(); + } else { + return new BasicRepresentation(data[identifier.path], identifier); + } + }, + async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise { + data[identifier.path] = await readableToString(representation.data); + }, + async deleteResource(identifier: ResourceIdentifier): Promise { + if (!data[identifier.path]) { + throw new NotFoundHttpError(); + } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete data[identifier.path]; + }, + } as any; + + storage = new JsonResourceStorage(store); + }); + + it('returns undefined if there is no matching data.', async(): Promise => { + await expect(storage.get(identifier1)).resolves.toBeUndefined(); + }); + + it('returns data if it was set beforehand.', async(): Promise => { + await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); + await expect(storage.get(identifier1)).resolves.toBe('apple'); + }); + + it('can check if data is present.', async(): Promise => { + await expect(storage.has(identifier1)).resolves.toBe(false); + await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); + await expect(storage.has(identifier1)).resolves.toBe(true); + }); + + it('can delete data.', async(): Promise => { + await expect(storage.has(identifier1)).resolves.toBe(false); + await expect(storage.delete(identifier1)).resolves.toBe(false); + await expect(storage.has(identifier1)).resolves.toBe(false); + await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); + await expect(storage.has(identifier1)).resolves.toBe(true); + await expect(storage.delete(identifier1)).resolves.toBe(true); + await expect(storage.has(identifier1)).resolves.toBe(false); + }); + + it('can handle multiple identifiers.', async(): Promise => { + await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); + await expect(storage.has(identifier1)).resolves.toBe(true); + await expect(storage.has(identifier2)).resolves.toBe(false); + await expect(storage.set(identifier2, 'pear')).resolves.toBe(storage); + await expect(storage.get(identifier1)).resolves.toBe('apple'); + }); + + it('re-throws errors thrown by the store.', async(): Promise => { + store.getRepresentation = jest.fn().mockRejectedValue(new Error('bad GET')); + await expect(storage.get(identifier1)).rejects.toThrow('bad GET'); + await expect(storage.has(identifier1)).rejects.toThrow('bad GET'); + + store.deleteResource = jest.fn().mockRejectedValue(new Error('bad DELETE')); + await expect(storage.delete(identifier1)).rejects.toThrow('bad DELETE'); + }); +}); diff --git a/test/unit/storage/keyvalue/MemoryMapStorage.test.ts b/test/unit/storage/keyvalue/MemoryMapStorage.test.ts new file mode 100644 index 000000000..da2a967b9 --- /dev/null +++ b/test/unit/storage/keyvalue/MemoryMapStorage.test.ts @@ -0,0 +1,45 @@ +import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import { MemoryMapStorage } from '../../../../src/storage/keyvalue/MemoryMapStorage'; + +describe('A MemoryMapStorage', (): void => { + const identifier1: ResourceIdentifier = { path: 'http://test.com/foo' }; + const identifier2: ResourceIdentifier = { path: 'http://test.com/bar' }; + let storage: MemoryMapStorage; + + beforeEach(async(): Promise => { + storage = new MemoryMapStorage(); + }); + + it('returns undefined if there is no matching data.', async(): Promise => { + await expect(storage.get(identifier1)).resolves.toBeUndefined(); + }); + + it('returns data if it was set beforehand.', async(): Promise => { + await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); + await expect(storage.get(identifier1)).resolves.toBe('apple'); + }); + + it('can check if data is present.', async(): Promise => { + await expect(storage.has(identifier1)).resolves.toBe(false); + await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); + await expect(storage.has(identifier1)).resolves.toBe(true); + }); + + it('can delete data.', async(): Promise => { + await expect(storage.has(identifier1)).resolves.toBe(false); + await expect(storage.delete(identifier1)).resolves.toBe(false); + await expect(storage.has(identifier1)).resolves.toBe(false); + await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); + await expect(storage.has(identifier1)).resolves.toBe(true); + await expect(storage.delete(identifier1)).resolves.toBe(true); + await expect(storage.has(identifier1)).resolves.toBe(false); + }); + + it('can handle multiple identifiers.', async(): Promise => { + await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); + await expect(storage.has(identifier1)).resolves.toBe(true); + await expect(storage.has(identifier2)).resolves.toBe(false); + await expect(storage.set(identifier2, 'pear')).resolves.toBe(storage); + await expect(storage.get(identifier1)).resolves.toBe('apple'); + }); +}); diff --git a/test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts b/test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts new file mode 100644 index 000000000..d1b26d2ed --- /dev/null +++ b/test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts @@ -0,0 +1,37 @@ +import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; +import { ResourceIdentifierStorage } from '../../../../src/storage/keyvalue/ResourceIdentifierStorage'; + +describe('A ResourceIdentifierStorage', (): void => { + const path = 'http://test.com/foo'; + const identifier = { path }; + let source: KeyValueStorage; + let storage: ResourceIdentifierStorage; + + beforeEach(async(): Promise => { + source = { + get: jest.fn(), + has: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + storage = new ResourceIdentifierStorage(source); + }); + + it('calls the corresponding function on the source Storage.', async(): Promise => { + await storage.get(identifier); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith(path); + + await storage.has(identifier); + expect(source.has).toHaveBeenCalledTimes(1); + expect(source.has).toHaveBeenLastCalledWith(path); + + await storage.set(identifier, 5); + expect(source.set).toHaveBeenCalledTimes(1); + expect(source.set).toHaveBeenLastCalledWith(path, 5); + + await storage.delete(identifier); + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith(path); + }); +});