mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Introduce internal storing mechanism
This commit is contained in:
@@ -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';
|
||||
|
||||
64
src/storage/keyvalue/JsonResourceStorage.ts
Normal file
64
src/storage/keyvalue/JsonResourceStorage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/storage/keyvalue/KeyValueStorage.ts
Normal file
36
src/storage/keyvalue/KeyValueStorage.ts
Normal 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>;
|
||||
}
|
||||
31
src/storage/keyvalue/MemoryMapStorage.ts
Normal file
31
src/storage/keyvalue/MemoryMapStorage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/storage/keyvalue/ResourceIdentifierStorage.ts
Normal file
33
src/storage/keyvalue/ResourceIdentifierStorage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
81
test/unit/storage/keyvalue/JsonResourceStorage.test.ts
Normal file
81
test/unit/storage/keyvalue/JsonResourceStorage.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
45
test/unit/storage/keyvalue/MemoryMapStorage.test.ts
Normal file
45
test/unit/storage/keyvalue/MemoryMapStorage.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
37
test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts
Normal file
37
test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user