From a28fb0258ff0de11243215112843c7903ecdfcf2 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 12 Feb 2021 11:27:20 +0100 Subject: [PATCH] feat: Use an IdentifierStrategy in InMemoryDataAccessor Now it's also possible to support multiple root containers. --- .../storage/backend/storage-memory.json | 4 +- src/storage/accessors/InMemoryDataAccessor.ts | 114 +++++++++--------- test/integration/LockingResourceStore.test.ts | 5 +- .../accessors/InMemoryDataAccessor.test.ts | 47 +++++++- 4 files changed, 104 insertions(+), 66 deletions(-) diff --git a/config/presets/storage/backend/storage-memory.json b/config/presets/storage/backend/storage-memory.json index 7d75437dc..027566b31 100644 --- a/config/presets/storage/backend/storage-memory.json +++ b/config/presets/storage/backend/storage-memory.json @@ -4,8 +4,8 @@ { "@id": "urn:solid-server:default:MemoryDataAccessor", "@type": "InMemoryDataAccessor", - "InMemoryDataAccessor:_base": { - "@id": "urn:solid-server:default:variable:baseUrl" + "InMemoryDataAccessor:_strategy": { + "@id": "urn:solid-server:default:IdentifierStrategy" } }, { diff --git a/src/storage/accessors/InMemoryDataAccessor.ts b/src/storage/accessors/InMemoryDataAccessor.ts index 1938cb430..b885e27a9 100644 --- a/src/storage/accessors/InMemoryDataAccessor.ts +++ b/src/storage/accessors/InMemoryDataAccessor.ts @@ -1,13 +1,12 @@ import type { Readable } from 'stream'; import arrayifyStream from 'arrayify-stream'; -import { DataFactory } from 'n3'; import type { NamedNode } from 'rdf-js'; import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import type { Guarded } from '../../util/GuardedStream'; -import { ensureTrailingSlash, isContainerIdentifier } from '../../util/PathUtil'; -import { generateContainmentQuads, generateResourceQuads } from '../../util/ResourceUtil'; +import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy'; +import { generateContainmentQuads } from '../../util/ResourceUtil'; import { guardedStreamFrom } from '../../util/StreamUtil'; import type { DataAccessor } from './DataAccessor'; @@ -22,15 +21,13 @@ interface ContainerEntry { type CacheEntry = DataEntry | ContainerEntry; export class InMemoryDataAccessor implements DataAccessor { - private readonly base: string; - // A dummy container with one entry which corresponds to the base - private readonly store: { entries: { ''?: ContainerEntry } }; + private readonly strategy: IdentifierStrategy; + // A dummy container where every entry corresponds to a root container + private readonly store: { entries: Record }; - public constructor(base: string) { - this.base = ensureTrailingSlash(base); + public constructor(strategy: IdentifierStrategy) { + this.strategy = strategy; - const metadata = new RepresentationMetadata({ path: this.base }); - metadata.addQuads(generateResourceQuads(DataFactory.namedNode(this.base), true)); this.store = { entries: { }}; } @@ -48,16 +45,13 @@ export class InMemoryDataAccessor implements DataAccessor { public async getMetadata(identifier: ResourceIdentifier): Promise { const entry = this.getEntry(identifier); - if (this.isDataEntry(entry) === isContainerIdentifier(identifier)) { - throw new NotFoundHttpError(); - } - return this.generateMetadata(identifier, entry); + return this.generateMetadata(entry); } public async writeDocument(identifier: ResourceIdentifier, data: Guarded, metadata: RepresentationMetadata): Promise { - const { parent, name } = this.getParentEntry(identifier); - parent.entries[name] = { + const parent = this.getParentEntry(identifier); + parent.entries[identifier.path] = { // Drain original stream and create copy data: await arrayifyStream(data), metadata, @@ -72,8 +66,8 @@ export class InMemoryDataAccessor implements DataAccessor { } catch (error: unknown) { // Create new entry if it didn't exist yet if (NotFoundHttpError.isInstance(error)) { - const { parent, name } = this.getParentEntry(identifier); - parent.entries[name] = { + const parent = this.getParentEntry(identifier); + parent.entries[identifier.path] = { entries: {}, metadata, }; @@ -84,65 +78,73 @@ export class InMemoryDataAccessor implements DataAccessor { } public async deleteResource(identifier: ResourceIdentifier): Promise { - const { parent, name } = this.getParentEntry(identifier); - if (!parent.entries[name]) { + const parent = this.getParentEntry(identifier); + if (!parent.entries[identifier.path]) { throw new NotFoundHttpError(); } // 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 { return Boolean((entry as DataEntry).data); } - private getParentEntry(identifier: ResourceIdentifier): { parent: ContainerEntry; name: string } { - if (identifier.path === this.base) { - // Casting is fine here as the parent should never be used as a real container - return { parent: this.store as any, name: '' }; + /** + * Generates an array of identifiers corresponding to the nested containers until the given identifier is reached. + * This does not verify if these identifiers actually exist. + */ + private getHierarchy(identifier: ResourceIdentifier): ResourceIdentifier[] { + if (this.strategy.isRootContainer(identifier)) { + return [ identifier ]; } - if (!this.store.entries['']) { - throw new NotFoundHttpError(); - } - - 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 }; + const hierarchy = this.getHierarchy(this.strategy.getParentContainer(identifier)); + hierarchy.push(identifier); + return hierarchy; } + /** + * 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 { - const { parent, name } = this.getParentEntry(identifier); - const entry = parent.entries[name]; + const parent = this.getParentEntry(identifier); + const entry = parent.entries[identifier.path]; if (!entry) { throw new NotFoundHttpError(); } return entry; } - private generateMetadata(identifier: ResourceIdentifier, entry: CacheEntry): RepresentationMetadata { + private generateMetadata(entry: CacheEntry): RepresentationMetadata { const metadata = new RepresentationMetadata(entry.metadata); if (!this.isDataEntry(entry)) { - const childNames = Object.keys(entry.entries).map((name): string => - `${identifier.path}${name}${this.isDataEntry(entry.entries[name]) ? '' : '/'}`); + const childNames = Object.keys(entry.entries); const quads = generateContainmentQuads(metadata.identifier as NamedNode, childNames); metadata.addQuads(quads); } diff --git a/test/integration/LockingResourceStore.test.ts b/test/integration/LockingResourceStore.test.ts index e24bf6ba5..eb28d2e15 100644 --- a/test/integration/LockingResourceStore.test.ts +++ b/test/integration/LockingResourceStore.test.ts @@ -35,9 +35,10 @@ describe('A LockingResourceStore', (): void => { const base = 'http://test.com/'; path = `${base}path`; + const idStrategy = new SingleRootIdentifierStrategy(base); source = new DataAccessorBasedStore( - new InMemoryDataAccessor(base), - new SingleRootIdentifierStrategy(base), + new InMemoryDataAccessor(idStrategy), + idStrategy, strategy, ); diff --git a/test/unit/storage/accessors/InMemoryDataAccessor.test.ts b/test/unit/storage/accessors/InMemoryDataAccessor.test.ts index c6efff338..d8dc6d57c 100644 --- a/test/unit/storage/accessors/InMemoryDataAccessor.test.ts +++ b/test/unit/storage/accessors/InMemoryDataAccessor.test.ts @@ -2,23 +2,35 @@ import 'jest-rdf'; import type { Readable } from 'stream'; import { DataFactory } from 'n3'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import { InMemoryDataAccessor } from '../../../../src/storage/accessors/InMemoryDataAccessor'; import { APPLICATION_OCTET_STREAM } from '../../../../src/util/ContentTypes'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; import type { Guarded } from '../../../../src/util/GuardedStream'; +import { BaseIdentifierStrategy } from '../../../../src/util/identifiers/BaseIdentifierStrategy'; import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil'; 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 => { - const base = 'http://test.com/'; + const base = 'http://test.com/root/'; let accessor: InMemoryDataAccessor; let metadata: RepresentationMetadata; let data: Guarded; beforeEach(async(): Promise => { - 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()); 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 => { - 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.'); }); @@ -77,7 +89,8 @@ describe('An InMemoryDataAccessor', (): void => { await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined(); await expect(accessor.writeDocument({ path: `${base}container/resource` }, data, metadata)) .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/` }); expect(metadata.getAll(LDP.contains)).toEqualRdfTermArray( [ 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 => { - 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)) .rejects.toThrow('Invalid path.'); @@ -185,4 +198,26 @@ describe('An InMemoryDataAccessor', (): void => { expect(resultMetadata.quads()).toBeRdfIsomorphic(metadata.quads()); }); }); + + describe('handling multiple root containers', (): void => { + const base2 = 'http://test2.com/root/'; + + beforeEach(async(): Promise => { + await accessor.writeContainer({ path: `${base2}` }, new RepresentationMetadata()); + }); + + it('can write to different root containers.', async(): Promise => { + 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 => { + await expect(accessor.deleteResource({ path: base })).resolves.toBeUndefined(); + await expect(accessor.getMetadata({ path: base2 })).resolves.toBeDefined(); + }); + }); });