feat: Introduce internal storing mechanism

This commit is contained in:
Joachim Van Herwegen
2021-02-03 15:46:32 +01:00
parent b61d46900f
commit 59deb989ec
8 changed files with 333 additions and 0 deletions

View File

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

View File

@@ -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<ResourceIdentifier, unknown> {
private readonly source: ResourceStore;
public constructor(source: ResourceStore) {
this.source = source;
}
public async get(identifier: ResourceIdentifier): Promise<unknown | undefined> {
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<boolean> {
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<this> {
const representation = new BasicRepresentation(JSON.stringify(value), identifier, 'application/json');
await this.source.setRepresentation(identifier, representation);
return this;
}
public async delete(identifier: ResourceIdentifier): Promise<boolean> {
try {
await this.source.deleteResource(identifier);
return true;
} catch (error: unknown) {
if (!NotFoundHttpError.isInstance(error)) {
throw error;
}
return false;
}
}
}

View File

@@ -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<TKey, TValue> {
/**
* 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<TValue | undefined>;
/**
* Checks if there is a value stored for the given key.
* @param identifier - Identifier to check.
*/
has: (key: TKey) => Promise<boolean>;
/**
* 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<this>;
/**
* 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<boolean>;
}

View File

@@ -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<TKey, TValue> implements KeyValueStorage<TKey, TValue> {
private readonly data: Map<TKey, TValue>;
public constructor() {
this.data = new Map<TKey, TValue>();
}
public async get(key: TKey): Promise<TValue | undefined> {
return this.data.get(key);
}
public async has(key: TKey): Promise<boolean> {
return this.data.has(key);
}
public async set(key: TKey, value: TValue): Promise<this> {
this.data.set(key, value);
return this;
}
public async delete(key: TKey): Promise<boolean> {
return this.data.delete(key);
}
}

View File

@@ -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<T> implements KeyValueStorage<ResourceIdentifier, T> {
private readonly source: KeyValueStorage<string, T>;
public constructor(source: KeyValueStorage<string, T>) {
this.source = source;
}
public async get(key: ResourceIdentifier): Promise<T | undefined> {
return this.source.get(key.path);
}
public async has(key: ResourceIdentifier): Promise<boolean> {
return this.source.has(key.path);
}
public async set(key: ResourceIdentifier, value: T): Promise<this> {
await this.source.set(key.path, value);
return this;
}
public async delete(key: ResourceIdentifier): Promise<boolean> {
return this.source.delete(key.path);
}
}

View File

@@ -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<void> => {
const data: Record<string, string> = { };
store = {
async getRepresentation(identifier: ResourceIdentifier): Promise<Representation> {
if (!data[identifier.path]) {
throw new NotFoundHttpError();
} else {
return new BasicRepresentation(data[identifier.path], identifier);
}
},
async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise<void> {
data[identifier.path] = await readableToString(representation.data);
},
async deleteResource(identifier: ResourceIdentifier): Promise<void> {
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<void> => {
await expect(storage.get(identifier1)).resolves.toBeUndefined();
});
it('returns data if it was set beforehand.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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');
});
});

View File

@@ -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<ResourceIdentifier, string>;
beforeEach(async(): Promise<void> => {
storage = new MemoryMapStorage<ResourceIdentifier, string>();
});
it('returns undefined if there is no matching data.', async(): Promise<void> => {
await expect(storage.get(identifier1)).resolves.toBeUndefined();
});
it('returns data if it was set beforehand.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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');
});
});

View File

@@ -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<string, number>;
let storage: ResourceIdentifierStorage<number>;
beforeEach(async(): Promise<void> => {
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<void> => {
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);
});
});