mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Use an IdentifierStrategy in InMemoryDataAccessor
Now it's also possible to support multiple root containers.
This commit is contained in:
parent
29df380396
commit
a28fb0258f
@ -4,8 +4,8 @@
|
|||||||
{
|
{
|
||||||
"@id": "urn:solid-server:default:MemoryDataAccessor",
|
"@id": "urn:solid-server:default:MemoryDataAccessor",
|
||||||
"@type": "InMemoryDataAccessor",
|
"@type": "InMemoryDataAccessor",
|
||||||
"InMemoryDataAccessor:_base": {
|
"InMemoryDataAccessor:_strategy": {
|
||||||
"@id": "urn:solid-server:default:variable:baseUrl"
|
"@id": "urn:solid-server:default:IdentifierStrategy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import type { Readable } from 'stream';
|
import type { Readable } from 'stream';
|
||||||
import arrayifyStream from 'arrayify-stream';
|
import arrayifyStream from 'arrayify-stream';
|
||||||
import { DataFactory } from 'n3';
|
|
||||||
import type { NamedNode } from 'rdf-js';
|
import type { NamedNode } from 'rdf-js';
|
||||||
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
|
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
|
||||||
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
|
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
|
||||||
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
|
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
|
||||||
import type { Guarded } from '../../util/GuardedStream';
|
import type { Guarded } from '../../util/GuardedStream';
|
||||||
import { ensureTrailingSlash, isContainerIdentifier } from '../../util/PathUtil';
|
import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy';
|
||||||
import { generateContainmentQuads, generateResourceQuads } from '../../util/ResourceUtil';
|
import { generateContainmentQuads } from '../../util/ResourceUtil';
|
||||||
import { guardedStreamFrom } from '../../util/StreamUtil';
|
import { guardedStreamFrom } from '../../util/StreamUtil';
|
||||||
import type { DataAccessor } from './DataAccessor';
|
import type { DataAccessor } from './DataAccessor';
|
||||||
|
|
||||||
@ -22,15 +21,13 @@ interface ContainerEntry {
|
|||||||
type CacheEntry = DataEntry | ContainerEntry;
|
type CacheEntry = DataEntry | ContainerEntry;
|
||||||
|
|
||||||
export class InMemoryDataAccessor implements DataAccessor {
|
export class InMemoryDataAccessor implements DataAccessor {
|
||||||
private readonly base: string;
|
private readonly strategy: IdentifierStrategy;
|
||||||
// A dummy container with one entry which corresponds to the base
|
// A dummy container where every entry corresponds to a root container
|
||||||
private readonly store: { entries: { ''?: ContainerEntry } };
|
private readonly store: { entries: Record<string, ContainerEntry> };
|
||||||
|
|
||||||
public constructor(base: string) {
|
public constructor(strategy: IdentifierStrategy) {
|
||||||
this.base = ensureTrailingSlash(base);
|
this.strategy = strategy;
|
||||||
|
|
||||||
const metadata = new RepresentationMetadata({ path: this.base });
|
|
||||||
metadata.addQuads(generateResourceQuads(DataFactory.namedNode(this.base), true));
|
|
||||||
this.store = { entries: { }};
|
this.store = { entries: { }};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,16 +45,13 @@ export class InMemoryDataAccessor implements DataAccessor {
|
|||||||
|
|
||||||
public async getMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
|
public async getMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
|
||||||
const entry = this.getEntry(identifier);
|
const entry = this.getEntry(identifier);
|
||||||
if (this.isDataEntry(entry) === isContainerIdentifier(identifier)) {
|
return this.generateMetadata(entry);
|
||||||
throw new NotFoundHttpError();
|
|
||||||
}
|
|
||||||
return this.generateMetadata(identifier, entry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async writeDocument(identifier: ResourceIdentifier, data: Guarded<Readable>, metadata: RepresentationMetadata):
|
public async writeDocument(identifier: ResourceIdentifier, data: Guarded<Readable>, metadata: RepresentationMetadata):
|
||||||
Promise<void> {
|
Promise<void> {
|
||||||
const { parent, name } = this.getParentEntry(identifier);
|
const parent = this.getParentEntry(identifier);
|
||||||
parent.entries[name] = {
|
parent.entries[identifier.path] = {
|
||||||
// Drain original stream and create copy
|
// Drain original stream and create copy
|
||||||
data: await arrayifyStream(data),
|
data: await arrayifyStream(data),
|
||||||
metadata,
|
metadata,
|
||||||
@ -72,8 +66,8 @@ export class InMemoryDataAccessor implements DataAccessor {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Create new entry if it didn't exist yet
|
// Create new entry if it didn't exist yet
|
||||||
if (NotFoundHttpError.isInstance(error)) {
|
if (NotFoundHttpError.isInstance(error)) {
|
||||||
const { parent, name } = this.getParentEntry(identifier);
|
const parent = this.getParentEntry(identifier);
|
||||||
parent.entries[name] = {
|
parent.entries[identifier.path] = {
|
||||||
entries: {},
|
entries: {},
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
@ -84,65 +78,73 @@ export class InMemoryDataAccessor implements DataAccessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
|
public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
|
||||||
const { parent, name } = this.getParentEntry(identifier);
|
const parent = this.getParentEntry(identifier);
|
||||||
if (!parent.entries[name]) {
|
if (!parent.entries[identifier.path]) {
|
||||||
throw new NotFoundHttpError();
|
throw new NotFoundHttpError();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
delete parent.entries[name];
|
delete parent.entries[identifier.path];
|
||||||
}
|
}
|
||||||
|
|
||||||
private isDataEntry(entry: CacheEntry): entry is DataEntry {
|
private isDataEntry(entry: CacheEntry): entry is DataEntry {
|
||||||
return Boolean((entry as DataEntry).data);
|
return Boolean((entry as DataEntry).data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getParentEntry(identifier: ResourceIdentifier): { parent: ContainerEntry; name: string } {
|
/**
|
||||||
if (identifier.path === this.base) {
|
* Generates an array of identifiers corresponding to the nested containers until the given identifier is reached.
|
||||||
// Casting is fine here as the parent should never be used as a real container
|
* This does not verify if these identifiers actually exist.
|
||||||
return { parent: this.store as any, name: '' };
|
*/
|
||||||
|
private getHierarchy(identifier: ResourceIdentifier): ResourceIdentifier[] {
|
||||||
|
if (this.strategy.isRootContainer(identifier)) {
|
||||||
|
return [ identifier ];
|
||||||
}
|
}
|
||||||
if (!this.store.entries['']) {
|
const hierarchy = this.getHierarchy(this.strategy.getParentContainer(identifier));
|
||||||
throw new NotFoundHttpError();
|
hierarchy.push(identifier);
|
||||||
}
|
return hierarchy;
|
||||||
|
|
||||||
const parts = identifier.path.slice(this.base.length).split('/').filter((part): boolean => part.length > 0);
|
|
||||||
|
|
||||||
// Name of the resource will be the last entry in the path
|
|
||||||
const name = parts[parts.length - 1];
|
|
||||||
|
|
||||||
// All names preceding the last should be nested containers
|
|
||||||
const containers = parts.slice(0, -1);
|
|
||||||
|
|
||||||
// Step through the parts of the path up to the end
|
|
||||||
// First entry is guaranteed to be a ContainerEntry
|
|
||||||
let parent = this.store.entries[''];
|
|
||||||
for (const container of containers) {
|
|
||||||
const child = parent.entries[container];
|
|
||||||
if (!child) {
|
|
||||||
throw new NotFoundHttpError();
|
|
||||||
} else if (this.isDataEntry(child)) {
|
|
||||||
throw new Error('Invalid path.');
|
|
||||||
}
|
|
||||||
parent = child;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { parent, name };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ContainerEntry corresponding to the parent container of the given identifier.
|
||||||
|
* Will throw 404 if the parent does not exist.
|
||||||
|
*/
|
||||||
|
private getParentEntry(identifier: ResourceIdentifier): ContainerEntry {
|
||||||
|
// Casting is fine here as the parent should never be used as a real container
|
||||||
|
let parent: CacheEntry = this.store as ContainerEntry;
|
||||||
|
if (this.strategy.isRootContainer(identifier)) {
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchy = this.getHierarchy(this.strategy.getParentContainer(identifier));
|
||||||
|
for (const entry of hierarchy) {
|
||||||
|
parent = parent.entries[entry.path];
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundHttpError();
|
||||||
|
}
|
||||||
|
if (this.isDataEntry(parent)) {
|
||||||
|
throw new Error('Invalid path.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the CacheEntry corresponding the given identifier.
|
||||||
|
* Will throw 404 if the resource does not exist.
|
||||||
|
*/
|
||||||
private getEntry(identifier: ResourceIdentifier): CacheEntry {
|
private getEntry(identifier: ResourceIdentifier): CacheEntry {
|
||||||
const { parent, name } = this.getParentEntry(identifier);
|
const parent = this.getParentEntry(identifier);
|
||||||
const entry = parent.entries[name];
|
const entry = parent.entries[identifier.path];
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new NotFoundHttpError();
|
throw new NotFoundHttpError();
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateMetadata(identifier: ResourceIdentifier, entry: CacheEntry): RepresentationMetadata {
|
private generateMetadata(entry: CacheEntry): RepresentationMetadata {
|
||||||
const metadata = new RepresentationMetadata(entry.metadata);
|
const metadata = new RepresentationMetadata(entry.metadata);
|
||||||
if (!this.isDataEntry(entry)) {
|
if (!this.isDataEntry(entry)) {
|
||||||
const childNames = Object.keys(entry.entries).map((name): string =>
|
const childNames = Object.keys(entry.entries);
|
||||||
`${identifier.path}${name}${this.isDataEntry(entry.entries[name]) ? '' : '/'}`);
|
|
||||||
const quads = generateContainmentQuads(metadata.identifier as NamedNode, childNames);
|
const quads = generateContainmentQuads(metadata.identifier as NamedNode, childNames);
|
||||||
metadata.addQuads(quads);
|
metadata.addQuads(quads);
|
||||||
}
|
}
|
||||||
|
@ -35,9 +35,10 @@ describe('A LockingResourceStore', (): void => {
|
|||||||
|
|
||||||
const base = 'http://test.com/';
|
const base = 'http://test.com/';
|
||||||
path = `${base}path`;
|
path = `${base}path`;
|
||||||
|
const idStrategy = new SingleRootIdentifierStrategy(base);
|
||||||
source = new DataAccessorBasedStore(
|
source = new DataAccessorBasedStore(
|
||||||
new InMemoryDataAccessor(base),
|
new InMemoryDataAccessor(idStrategy),
|
||||||
new SingleRootIdentifierStrategy(base),
|
idStrategy,
|
||||||
strategy,
|
strategy,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -2,23 +2,35 @@ import 'jest-rdf';
|
|||||||
import type { Readable } from 'stream';
|
import type { Readable } from 'stream';
|
||||||
import { DataFactory } from 'n3';
|
import { DataFactory } from 'n3';
|
||||||
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
|
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
|
||||||
|
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
|
||||||
import { InMemoryDataAccessor } from '../../../../src/storage/accessors/InMemoryDataAccessor';
|
import { InMemoryDataAccessor } from '../../../../src/storage/accessors/InMemoryDataAccessor';
|
||||||
import { APPLICATION_OCTET_STREAM } from '../../../../src/util/ContentTypes';
|
import { APPLICATION_OCTET_STREAM } from '../../../../src/util/ContentTypes';
|
||||||
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
|
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
|
||||||
import type { Guarded } from '../../../../src/util/GuardedStream';
|
import type { Guarded } from '../../../../src/util/GuardedStream';
|
||||||
|
import { BaseIdentifierStrategy } from '../../../../src/util/identifiers/BaseIdentifierStrategy';
|
||||||
import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil';
|
import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil';
|
||||||
import { LDP, RDF } from '../../../../src/util/Vocabularies';
|
import { LDP, RDF } from '../../../../src/util/Vocabularies';
|
||||||
|
|
||||||
|
class DummyStrategy extends BaseIdentifierStrategy {
|
||||||
|
public supportsIdentifier(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRootContainer(identifier: ResourceIdentifier): boolean {
|
||||||
|
return identifier.path.endsWith('root/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('An InMemoryDataAccessor', (): void => {
|
describe('An InMemoryDataAccessor', (): void => {
|
||||||
const base = 'http://test.com/';
|
const base = 'http://test.com/root/';
|
||||||
let accessor: InMemoryDataAccessor;
|
let accessor: InMemoryDataAccessor;
|
||||||
let metadata: RepresentationMetadata;
|
let metadata: RepresentationMetadata;
|
||||||
let data: Guarded<Readable>;
|
let data: Guarded<Readable>;
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
accessor = new InMemoryDataAccessor(base);
|
accessor = new InMemoryDataAccessor(new DummyStrategy());
|
||||||
|
|
||||||
// Create default root container
|
// Most tests depend on there already being a root container
|
||||||
await accessor.writeContainer({ path: `${base}` }, new RepresentationMetadata());
|
await accessor.writeContainer({ path: `${base}` }, new RepresentationMetadata());
|
||||||
|
|
||||||
metadata = new RepresentationMetadata(APPLICATION_OCTET_STREAM);
|
metadata = new RepresentationMetadata(APPLICATION_OCTET_STREAM);
|
||||||
@ -41,7 +53,7 @@ describe('An InMemoryDataAccessor', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws an error if part of the path matches a document.', async(): Promise<void> => {
|
it('throws an error if part of the path matches a document.', async(): Promise<void> => {
|
||||||
await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
|
await expect(accessor.writeDocument({ path: `${base}resource/` }, data, metadata)).resolves.toBeUndefined();
|
||||||
await expect(accessor.getData({ path: `${base}resource/resource2` })).rejects.toThrow('Invalid path.');
|
await expect(accessor.getData({ path: `${base}resource/resource2` })).rejects.toThrow('Invalid path.');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -77,7 +89,8 @@ describe('An InMemoryDataAccessor', (): void => {
|
|||||||
await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined();
|
await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined();
|
||||||
await expect(accessor.writeDocument({ path: `${base}container/resource` }, data, metadata))
|
await expect(accessor.writeDocument({ path: `${base}container/resource` }, data, metadata))
|
||||||
.resolves.toBeUndefined();
|
.resolves.toBeUndefined();
|
||||||
await expect(accessor.writeContainer({ path: `${base}container/container2` }, metadata)).resolves.toBeUndefined();
|
await expect(accessor.writeContainer({ path: `${base}container/container2/` }, metadata))
|
||||||
|
.resolves.toBeUndefined();
|
||||||
metadata = await accessor.getMetadata({ path: `${base}container/` });
|
metadata = await accessor.getMetadata({ path: `${base}container/` });
|
||||||
expect(metadata.getAll(LDP.contains)).toEqualRdfTermArray(
|
expect(metadata.getAll(LDP.contains)).toEqualRdfTermArray(
|
||||||
[ DataFactory.namedNode(`${base}container/resource`), DataFactory.namedNode(`${base}container/container2/`) ],
|
[ DataFactory.namedNode(`${base}container/resource`), DataFactory.namedNode(`${base}container/container2/`) ],
|
||||||
@ -155,7 +168,7 @@ describe('An InMemoryDataAccessor', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('errors when writing to an invalid container path..', async(): Promise<void> => {
|
it('errors when writing to an invalid container path..', async(): Promise<void> => {
|
||||||
await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
|
await expect(accessor.writeDocument({ path: `${base}resource/` }, data, metadata)).resolves.toBeUndefined();
|
||||||
|
|
||||||
await expect(accessor.writeContainer({ path: `${base}resource/container` }, metadata))
|
await expect(accessor.writeContainer({ path: `${base}resource/container` }, metadata))
|
||||||
.rejects.toThrow('Invalid path.');
|
.rejects.toThrow('Invalid path.');
|
||||||
@ -185,4 +198,26 @@ describe('An InMemoryDataAccessor', (): void => {
|
|||||||
expect(resultMetadata.quads()).toBeRdfIsomorphic(metadata.quads());
|
expect(resultMetadata.quads()).toBeRdfIsomorphic(metadata.quads());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('handling multiple root containers', (): void => {
|
||||||
|
const base2 = 'http://test2.com/root/';
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
await accessor.writeContainer({ path: `${base2}` }, new RepresentationMetadata());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can write to different root containers.', async(): Promise<void> => {
|
||||||
|
await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
|
||||||
|
data = guardedStreamFrom([ 'data2' ]);
|
||||||
|
await expect(accessor.writeDocument({ path: `${base2}resource` }, data, metadata)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await expect(readableToString(await accessor.getData({ path: `${base}resource` }))).resolves.toBe('data');
|
||||||
|
await expect(readableToString(await accessor.getData({ path: `${base2}resource` }))).resolves.toBe('data2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting a root container does not delete others.', async(): Promise<void> => {
|
||||||
|
await expect(accessor.deleteResource({ path: base })).resolves.toBeUndefined();
|
||||||
|
await expect(accessor.getMetadata({ path: base2 })).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user