feat: Rework ResourceStore to return extra info

* feat: change return types in ResourceStore.ts

* feat: change return types in BaseResourceStore.ts

* feat: change return types in LockingResourceStore.ts

* feat: change return types in RoutingResourceStore.ts

* feat: change return types in MonitoringStore.ts

* feat: change return types in PassthroughStore.ts

* feat: change return types in ReadOnlyStore.ts

* feat: change return types in PatchHandler.ts

* feat: change return types in PatchingStore.ts

* feat: change return types in RepresentationPatchHandler.ts

* feat: create createResourceIdentifier() function for convenience

* feat: adapt PostOperationHandler.ts to new typing

* feat: change return types in RepresentationConvertingStore.ts

* feat: adapt DataAccessorBasedStore.ts implementation to new typings

* feat: adapt UnsecureWebSocketsProtocol.ts to new typing

* chore: add temporary comments

* fix: return correct Location header on POST request with slug

* fix: npm run lint command needs more packages

* fix: linting errors

* chore: revert ed9952b

* test: adapt PostOperationHandler tests

* test: adapt UnsecureWebSocketsProtocol tests

* test: adapt DataAccessorBasedStore tests

* fix: linting errors

* feat: emit specific created, deleted, updated events in MonitoringStore

* test: adapt RepresentationPatchHandler tests

* fix: revert UnsecureWebSocketsProtocol changes

* feat: emit extra parameter on changed

* test: adapt MonitoringStore tests

* fix: linting errors

* test: add test to MonitorStore.test for coverage

* fix: linting error

* chore: update doc in ResourceStore.ts

* test: improve MonitoringStore tests

* chore: update RELEASE_NOTES.md

* chore: add extra info about the MonitoringStore to documentation/resource-store.md

* chore: Update RELEASE_NOTES.md

Co-authored-by: Anton Wiklund <ixuz07@gmail.com>

* chore: Update documentation/resource-store.md

Co-authored-by: Anton Wiklund <ixuz07@gmail.com>

* chore: very small changes

* chore: simplify metadata creation

* fix: DataAccessorBasedStore improvement and bugfix

* chore: improve resource-store.md

* chore: adapt MonitoringStore event names, update docs and apply code suggestion

* chore: use ResourceStoreResponse type

* fix: typo

* chore: rename ResourceStoreResponse type to ChangeMap

* chore: adapt .gitignore to name change

Co-authored-by: Anton Wiklund <ixuz07@gmail.com>
This commit is contained in:
Arthur Joppart 2022-07-06 14:40:28 +02:00 committed by GitHub
parent 1c65b06392
commit e0954cf2a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 468 additions and 217 deletions

View File

@ -3,6 +3,7 @@
"BaseHttpError", "BaseHttpError",
"BasicConditions", "BasicConditions",
"BasicRepresentation", "BasicRepresentation",
"ChangeMap",
"CredentialSet", "CredentialSet",
"Dict", "Dict",
"Error", "Error",

View File

@ -65,6 +65,7 @@ These changes are relevant if you wrote custom modules for the server that depen
* Changed type for `finalizer` attribute in `App` from `Finalizable` to `Finalizer` and updated the calling code in `App.stop()`. * Changed type for `finalizer` attribute in `App` from `Finalizable` to `Finalizer` and updated the calling code in `App.stop()`.
* Removed the now obsolete `ParallelFinalizer` util class. * Removed the now obsolete `ParallelFinalizer` util class.
- Added a lock cleanup on initialize for lock implementations `RedisLocker` and `FileSystemResourceLocker`. - Added a lock cleanup on initialize for lock implementations `RedisLocker` and `FileSystemResourceLocker`.
- `ResourceStore` functions that change a resource now return metadata for every changed resource.
A new interface `SingleThreaded` has been added. This empty interface can be implemented to mark a component as not-threadsafe. When the CSS starts in multithreaded mode, it will error and halt if any SingleThreaded components are instantiated. A new interface `SingleThreaded` has been added. This empty interface can be implemented to mark a component as not-threadsafe. When the CSS starts in multithreaded mode, it will error and halt if any SingleThreaded components are instantiated.

View File

@ -29,6 +29,16 @@ and all the entries in `config/storage/backend`.
## MonitoringStore ## MonitoringStore
This store emits the events that are necessary to emit notifications when resources change. This store emits the events that are necessary to emit notifications when resources change.
There are 4 different events that can be emitted:
- `this.emit('changed', identifier, AS.Create | AS.Update | AS.Delete | undefined)`: is emitted for every resource that was changed/effected by a call to the store.
- `this.emit(AS.Create, identifier)`: is emitted for every resource that was created by the call to the store.
- `this.emit(AS.Update, identifier)`: is emitted for every resource that was updated by the call to the store.
- `this.emit(AS.Delete, identifier)`: is emitted for every resource that was deleted by the call to the store.
A `changed` event will always be emitted if a resource was changed.
If the correct metadata was set by the source `ResourceStore`, an additional field will be sent along indicating the type of change,
and an additional corresponding event will be emitted, depending on what the change is.
## IndexRepresentationStore ## IndexRepresentationStore
When doing a GET request on a container `/container/`, When doing a GET request on a container `/container/`,
this container returns the contents of `/container/index.html` instead if HTML is the preferred response type. this container returns the contents of `/container/index.html` instead if HTML is the preferred response type.

View File

@ -2,8 +2,10 @@ import { getLoggerFor } from '../../logging/LogUtil';
import type { ResourceStore } from '../../storage/ResourceStore'; import type { ResourceStore } from '../../storage/ResourceStore';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { AS, SOLID_AS } from '../../util/Vocabularies';
import { CreatedResponseDescription } from '../output/response/CreatedResponseDescription'; import { CreatedResponseDescription } from '../output/response/CreatedResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription'; import type { ResponseDescription } from '../output/response/ResponseDescription';
import { createResourceIdentifier } from '../representation/ResourceIdentifier';
import type { OperationHandlerInput } from './OperationHandler'; import type { OperationHandlerInput } from './OperationHandler';
import { OperationHandler } from './OperationHandler'; import { OperationHandler } from './OperationHandler';
@ -35,7 +37,10 @@ export class PostOperationHandler extends OperationHandler {
this.logger.warn('POST requests require the Content-Type header to be set'); this.logger.warn('POST requests require the Content-Type header to be set');
throw new BadRequestHttpError('POST requests require the Content-Type header to be set'); throw new BadRequestHttpError('POST requests require the Content-Type header to be set');
} }
const identifier = await this.store.addResource(operation.target, operation.body, operation.conditions); const result = await this.store.addResource(operation.target, operation.body, operation.conditions);
return new CreatedResponseDescription(identifier); const createdIdentifier = Object.entries(result).find(
([ , value ]): boolean => value.get(SOLID_AS.terms.Activity)?.value === AS.Create,
)![0];
return new CreatedResponseDescription(createResourceIdentifier(createdIdentifier));
} }
} }

View File

@ -9,8 +9,15 @@ export interface ResourceIdentifier {
} }
/** /**
* Determines whether the object is a `ResourceIdentifier`. * Determines whether the object is a {@link ResourceIdentifier}.
*/ */
export function isResourceIdentifier(object: any): object is ResourceIdentifier { export function isResourceIdentifier(object: any): object is ResourceIdentifier {
return object && (typeof object.path === 'string'); return object && (typeof object.path === 'string');
} }
/**
* Factory function creating a {@link ResourceIdentifier} for convenience.
*/
export function createResourceIdentifier(resourcePath: string): ResourceIdentifier {
return { path: resourcePath };
}

View File

@ -4,7 +4,7 @@ import type { RepresentationPreferences } from '../http/representation/Represent
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
/** /**
* Base implementation of ResourceStore for implementers of custom stores. * Base implementation of ResourceStore for implementers of custom stores.
@ -21,22 +21,22 @@ export class BaseResourceStore implements ResourceStore {
} }
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
throw new NotImplementedHttpError(); throw new NotImplementedHttpError();
} }
public async addResource(container: ResourceIdentifier, representation: Representation, public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> { conditions?: Conditions): Promise<ChangeMap> {
throw new NotImplementedHttpError(); throw new NotImplementedHttpError();
} }
public async deleteResource(identifier: ResourceIdentifier, public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
throw new NotImplementedHttpError(); throw new NotImplementedHttpError();
} }
public async modifyResource(identifier: ResourceIdentifier, patch: Patch, public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
throw new NotImplementedHttpError(); throw new NotImplementedHttpError();
} }
} }

View File

@ -6,7 +6,7 @@ import type { AuxiliaryStrategy } from '../http/auxiliary/AuxiliaryStrategy';
import { BasicRepresentation } from '../http/representation/BasicRepresentation'; import { BasicRepresentation } from '../http/representation/BasicRepresentation';
import type { Patch } from '../http/representation/Patch'; import type { Patch } from '../http/representation/Patch';
import type { Representation } from '../http/representation/Representation'; import type { Representation } from '../http/representation/Representation';
import type { RepresentationMetadata } from '../http/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import { INTERNAL_QUADS } from '../util/ContentTypes'; import { INTERNAL_QUADS } from '../util/ContentTypes';
@ -39,10 +39,12 @@ import {
SOLID_META, SOLID_META,
PREFERRED_PREFIX_TERM, PREFERRED_PREFIX_TERM,
CONTENT_TYPE_TERM, CONTENT_TYPE_TERM,
SOLID_AS,
AS,
} from '../util/Vocabularies'; } from '../util/Vocabularies';
import type { DataAccessor } from './accessors/DataAccessor'; import type { DataAccessor } from './accessors/DataAccessor';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
/** /**
* ResourceStore which uses a DataAccessor for backend access. * ResourceStore which uses a DataAccessor for backend access.
@ -138,7 +140,7 @@ export class DataAccessorBasedStore implements ResourceStore {
} }
public async addResource(container: ResourceIdentifier, representation: Representation, conditions?: Conditions): public async addResource(container: ResourceIdentifier, representation: Representation, conditions?: Conditions):
Promise<ResourceIdentifier> { Promise<ChangeMap> {
this.validateIdentifier(container); this.validateIdentifier(container);
const parentMetadata = await this.getSafeNormalizedMetadata(container); const parentMetadata = await this.getSafeNormalizedMetadata(container);
@ -174,13 +176,11 @@ export class DataAccessorBasedStore implements ResourceStore {
} }
// Write the data. New containers should never be made for a POST request. // Write the data. New containers should never be made for a POST request.
await this.writeData(newID, representation, isContainer, false, false); return this.writeData(newID, representation, isContainer, false, false);
return newID;
} }
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
this.validateIdentifier(identifier); this.validateIdentifier(identifier);
// Check if the resource already exists // Check if the resource already exists
@ -216,7 +216,7 @@ export class DataAccessorBasedStore implements ResourceStore {
} }
public async modifyResource(identifier: ResourceIdentifier, patch: Patch, public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<never> {
if (conditions) { if (conditions) {
let metadata: RepresentationMetadata | undefined; let metadata: RepresentationMetadata | undefined;
try { try {
@ -233,7 +233,7 @@ export class DataAccessorBasedStore implements ResourceStore {
throw new NotImplementedHttpError('Patches are not supported by the default store.'); throw new NotImplementedHttpError('Patches are not supported by the default store.');
} }
public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise<ResourceIdentifier[]> { public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise<ChangeMap> {
this.validateIdentifier(identifier); this.validateIdentifier(identifier);
const metadata = await this.accessor.getMetadata(identifier); const metadata = await this.accessor.getMetadata(identifier);
// Solid, §5.4: "When a DELETE request targets storages root container or its associated ACL resource, // Solid, §5.4: "When a DELETE request targets storages root container or its associated ACL resource,
@ -266,22 +266,25 @@ export class DataAccessorBasedStore implements ResourceStore {
// Solid, §5.4: "When a contained resource is deleted, // Solid, §5.4: "When a contained resource is deleted,
// the server MUST also delete the associated auxiliary resources" // the server MUST also delete the associated auxiliary resources"
// https://solid.github.io/specification/protocol#deleting-resources // https://solid.github.io/specification/protocol#deleting-resources
const deleted = [ identifier ]; const changes: ChangeMap = {};
if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) { if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) {
const auxiliaries = this.auxiliaryStrategy.getAuxiliaryIdentifiers(identifier); const auxiliaries = this.auxiliaryStrategy.getAuxiliaryIdentifiers(identifier);
deleted.push(...await this.safelyDeleteAuxiliaryResources(auxiliaries)); for (const deletedId of await this.safelyDeleteAuxiliaryResources(auxiliaries)) {
changes[deletedId.path] = this.createActivityMetadata(deletedId, AS.Delete);
}
} }
if (!this.identifierStrategy.isRootContainer(identifier)) { if (!this.identifierStrategy.isRootContainer(identifier)) {
const container = this.identifierStrategy.getParentContainer(identifier); const container = this.identifierStrategy.getParentContainer(identifier);
deleted.push(container); changes[container.path] = this.createActivityMetadata(container, AS.Update);
// Update modified date of parent // Update modified date of parent
await this.updateContainerModifiedDate(container); await this.updateContainerModifiedDate(container);
} }
await this.accessor.deleteResource(identifier); await this.accessor.deleteResource(identifier);
return deleted; changes[identifier.path] = this.createActivityMetadata(identifier, AS.Delete);
return changes;
} }
/** /**
@ -359,7 +362,7 @@ export class DataAccessorBasedStore implements ResourceStore {
* @returns Identifiers of resources that were possibly modified. * @returns Identifiers of resources that were possibly modified.
*/ */
protected async writeData(identifier: ResourceIdentifier, representation: Representation, isContainer: boolean, protected async writeData(identifier: ResourceIdentifier, representation: Representation, isContainer: boolean,
createContainers: boolean, exists: boolean): Promise<ResourceIdentifier[]> { createContainers: boolean, exists: boolean): Promise<ChangeMap> {
// Make sure the metadata has the correct identifier and correct type quads // Make sure the metadata has the correct identifier and correct type quads
// Need to do this before handling container data to have the correct identifier // Need to do this before handling container data to have the correct identifier
representation.metadata.identifier = DataFactory.namedNode(identifier.path); representation.metadata.identifier = DataFactory.namedNode(identifier.path);
@ -382,18 +385,22 @@ export class DataAccessorBasedStore implements ResourceStore {
// Solid, §5.3: "Servers MUST create intermediate containers and include corresponding containment triples // Solid, §5.3: "Servers MUST create intermediate containers and include corresponding containment triples
// in container representations derived from the URI path component of PUT and PATCH requests." // in container representations derived from the URI path component of PUT and PATCH requests."
// https://solid.github.io/specification/protocol#writing-resources // https://solid.github.io/specification/protocol#writing-resources
const modified = []; let changes: ChangeMap = {};
if (!this.identifierStrategy.isRootContainer(identifier) && !exists) { if (!this.identifierStrategy.isRootContainer(identifier) && !exists) {
const container = this.identifierStrategy.getParentContainer(identifier); const parent = this.identifierStrategy.getParentContainer(identifier);
if (!createContainers) { if (!createContainers) {
modified.push(container); changes[parent.path] = this.createActivityMetadata(parent, AS.Update);
} else { } else {
const created = await this.createRecursiveContainers(container); const createdContainers = await this.createRecursiveContainers(parent);
modified.push(...created.length === 0 ? [ container ] : created); changes = { ...changes, ...createdContainers };
if (Object.keys(createdContainers).length === 0) {
changes[parent.path] = this.createActivityMetadata(parent, AS.Update);
}
} }
// Parent container is also modified // Parent container is also modified
await this.updateContainerModifiedDate(container); await this.updateContainerModifiedDate(parent);
} }
// Remove all generated metadata to prevent it from being stored permanently // Remove all generated metadata to prevent it from being stored permanently
@ -403,7 +410,8 @@ export class DataAccessorBasedStore implements ResourceStore {
this.accessor.writeContainer(identifier, representation.metadata) : this.accessor.writeContainer(identifier, representation.metadata) :
this.accessor.writeDocument(identifier, representation.data, representation.metadata)); this.accessor.writeDocument(identifier, representation.data, representation.metadata));
return [ ...modified, identifier ]; changes[identifier.path] = this.createActivityMetadata(identifier, exists ? AS.Update : AS.Create);
return changes;
} }
/** /**
@ -597,7 +605,7 @@ export class DataAccessorBasedStore implements ResourceStore {
* Will throw errors if the identifier of the last existing "container" corresponds to an existing document. * Will throw errors if the identifier of the last existing "container" corresponds to an existing document.
* @param container - Identifier of the container which will need to exist. * @param container - Identifier of the container which will need to exist.
*/ */
protected async createRecursiveContainers(container: ResourceIdentifier): Promise<ResourceIdentifier[]> { protected async createRecursiveContainers(container: ResourceIdentifier): Promise<ChangeMap> {
// Verify whether the container already exists // Verify whether the container already exists
try { try {
const metadata = await this.getNormalizedMetadata(container); const metadata = await this.getNormalizedMetadata(container);
@ -609,7 +617,7 @@ export class DataAccessorBasedStore implements ResourceStore {
if (!isContainerPath(metadata.identifier.value)) { if (!isContainerPath(metadata.identifier.value)) {
throw new ForbiddenHttpError(`Creating container ${container.path} conflicts with an existing resource.`); throw new ForbiddenHttpError(`Creating container ${container.path} conflicts with an existing resource.`);
} }
return []; return {};
} catch (error: unknown) { } catch (error: unknown) {
if (!NotFoundHttpError.isInstance(error)) { if (!NotFoundHttpError.isInstance(error)) {
throw error; throw error;
@ -618,9 +626,14 @@ export class DataAccessorBasedStore implements ResourceStore {
// Create the container, starting with its parent // Create the container, starting with its parent
const ancestors = this.identifierStrategy.isRootContainer(container) ? const ancestors = this.identifierStrategy.isRootContainer(container) ?
[] : {} :
await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(container)); await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(container));
await this.writeData(container, new BasicRepresentation([], container), true, false, false); const changes = await this.writeData(container, new BasicRepresentation([], container), true, false, false);
return [ ...ancestors, container ];
return { ...changes, ...ancestors };
}
private createActivityMetadata(id: ResourceIdentifier, activity: string): RepresentationMetadata {
return new RepresentationMetadata(id, { [SOLID_AS.terms.Activity.value]: activity });
} }
} }

View File

@ -10,7 +10,7 @@ import type { ExpiringReadWriteLocker } from '../util/locking/ExpiringReadWriteL
import { endOfStream } from '../util/StreamUtil'; import { endOfStream } from '../util/StreamUtil';
import type { AtomicResourceStore } from './AtomicResourceStore'; import type { AtomicResourceStore } from './AtomicResourceStore';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
/** /**
* Store that for every call acquires a lock before executing it on the requested resource, * Store that for every call acquires a lock before executing it on the requested resource,
@ -46,27 +46,27 @@ export class LockingResourceStore implements AtomicResourceStore {
} }
public async addResource(container: ResourceIdentifier, representation: Representation, public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> { conditions?: Conditions): Promise<ChangeMap> {
return this.locks.withWriteLock(this.getLockIdentifier(container), return this.locks.withWriteLock(this.getLockIdentifier(container),
async(): Promise<ResourceIdentifier> => this.source.addResource(container, representation, conditions)); async(): Promise<ChangeMap> => this.source.addResource(container, representation, conditions));
} }
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.locks.withWriteLock(this.getLockIdentifier(identifier), return this.locks.withWriteLock(this.getLockIdentifier(identifier),
async(): Promise<ResourceIdentifier[]> => this.source.setRepresentation(identifier, representation, conditions)); async(): Promise<ChangeMap> => this.source.setRepresentation(identifier, representation, conditions));
} }
public async deleteResource(identifier: ResourceIdentifier, public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.locks.withWriteLock(this.getLockIdentifier(identifier), return this.locks.withWriteLock(this.getLockIdentifier(identifier),
async(): Promise<ResourceIdentifier[]> => this.source.deleteResource(identifier, conditions)); async(): Promise<ChangeMap> => this.source.deleteResource(identifier, conditions));
} }
public async modifyResource(identifier: ResourceIdentifier, patch: Patch, public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.locks.withWriteLock(this.getLockIdentifier(identifier), return this.locks.withWriteLock(this.getLockIdentifier(identifier),
async(): Promise<ResourceIdentifier[]> => this.source.modifyResource(identifier, patch, conditions)); async(): Promise<ChangeMap> => this.source.modifyResource(identifier, patch, conditions));
} }
/** /**

View File

@ -3,8 +3,9 @@ import type { Patch } from '../http/representation/Patch';
import type { Representation } from '../http/representation/Representation'; import type { Representation } from '../http/representation/Representation';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import { AS, SOLID_AS } from '../util/Vocabularies';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
/** /**
* Store that notifies listeners of changes to its source * Store that notifies listeners of changes to its source
@ -29,31 +30,34 @@ export class MonitoringStore<T extends ResourceStore = ResourceStore>
} }
public async addResource(container: ResourceIdentifier, representation: Representation, public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> { conditions?: Conditions): Promise<ChangeMap> {
const identifier = await this.source.addResource(container, representation, conditions); return this.emitChanged(await this.source.addResource(container, representation, conditions));
this.emitChanged([ container, identifier ]);
return identifier;
} }
public async deleteResource(identifier: ResourceIdentifier, public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.emitChanged(await this.source.deleteResource(identifier, conditions)); return this.emitChanged(await this.source.deleteResource(identifier, conditions));
} }
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.emitChanged(await this.source.setRepresentation(identifier, representation, conditions)); return this.emitChanged(await this.source.setRepresentation(identifier, representation, conditions));
} }
public async modifyResource(identifier: ResourceIdentifier, patch: Patch, public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.emitChanged(await this.source.modifyResource(identifier, patch, conditions)); return this.emitChanged(await this.source.modifyResource(identifier, patch, conditions));
} }
private emitChanged(identifiers: ResourceIdentifier[]): typeof identifiers { private emitChanged(changes: ChangeMap): ChangeMap {
for (const identifier of identifiers) { for (const [ key, value ] of Object.entries(changes)) {
this.emit('changed', identifier); const activity = value.get(SOLID_AS.terms.Activity)?.value;
this.emit('changed', { path: key }, activity);
if (activity && [ AS.Create, AS.Delete, AS.Update ].includes(activity)) {
this.emit(activity, { path: key });
}
} }
return identifiers;
return changes;
} }
} }

View File

@ -3,7 +3,7 @@ import type { Representation } from '../http/representation/Representation';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
/** /**
* Store that calls the corresponding functions of the source Store. * Store that calls the corresponding functions of the source Store.
@ -27,22 +27,22 @@ export class PassthroughStore<T extends ResourceStore = ResourceStore> implement
} }
public async addResource(container: ResourceIdentifier, representation: Representation, public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> { conditions?: Conditions): Promise<ChangeMap> {
return this.source.addResource(container, representation, conditions); return this.source.addResource(container, representation, conditions);
} }
public async deleteResource(identifier: ResourceIdentifier, public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.source.deleteResource(identifier, conditions); return this.source.deleteResource(identifier, conditions);
} }
public async modifyResource(identifier: ResourceIdentifier, patch: Patch, public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.source.modifyResource(identifier, patch, conditions); return this.source.modifyResource(identifier, patch, conditions);
} }
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return this.source.setRepresentation(identifier, representation, conditions); return this.source.setRepresentation(identifier, representation, conditions);
} }
} }

View File

@ -4,7 +4,7 @@ import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import { PassthroughStore } from './PassthroughStore'; import { PassthroughStore } from './PassthroughStore';
import type { PatchHandler } from './patch/PatchHandler'; import type { PatchHandler } from './patch/PatchHandler';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
/** /**
* {@link ResourceStore} using decorator pattern for the `modifyResource` function. * {@link ResourceStore} using decorator pattern for the `modifyResource` function.
@ -20,7 +20,7 @@ export class PatchingStore<T extends ResourceStore = ResourceStore> extends Pass
} }
public async modifyResource(identifier: ResourceIdentifier, patch: Patch, public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
try { try {
return await this.source.modifyResource(identifier, patch, conditions); return await this.source.modifyResource(identifier, patch, conditions);
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -4,7 +4,7 @@ import type { ResourceIdentifier } from '../http/representation/ResourceIdentifi
import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError'; import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import { PassthroughStore } from './PassthroughStore'; import { PassthroughStore } from './PassthroughStore';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
/** /**
* Store that only allow read operations on the underlying source. * Store that only allow read operations on the underlying source.
@ -16,22 +16,22 @@ export class ReadOnlyStore<T extends ResourceStore = ResourceStore> extends Pass
} }
public async addResource(container: ResourceIdentifier, representation: Representation, public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> { conditions?: Conditions): Promise<ChangeMap> {
throw new ForbiddenHttpError(); throw new ForbiddenHttpError();
} }
public async deleteResource(identifier: ResourceIdentifier, public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
throw new ForbiddenHttpError(); throw new ForbiddenHttpError();
} }
public async modifyResource(identifier: ResourceIdentifier, patch: Patch, public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
throw new ForbiddenHttpError(); throw new ForbiddenHttpError();
} }
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
throw new ForbiddenHttpError(); throw new ForbiddenHttpError();
} }
} }

View File

@ -6,7 +6,7 @@ import type { Conditions } from './Conditions';
import { PassthroughConverter } from './conversion/PassthroughConverter'; import { PassthroughConverter } from './conversion/PassthroughConverter';
import type { RepresentationConverter } from './conversion/RepresentationConverter'; import type { RepresentationConverter } from './conversion/RepresentationConverter';
import { PassthroughStore } from './PassthroughStore'; import { PassthroughStore } from './PassthroughStore';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
/** /**
* Store that provides (optional) conversion of incoming and outgoing {@link Representation}s. * Store that provides (optional) conversion of incoming and outgoing {@link Representation}s.
@ -40,7 +40,7 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
} }
public async addResource(identifier: ResourceIdentifier, representation: Representation, public async addResource(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> { conditions?: Conditions): Promise<ChangeMap> {
// We can potentially run into problems here if we convert a turtle document where the base IRI is required, // We can potentially run into problems here if we convert a turtle document where the base IRI is required,
// since we don't know the resource IRI yet at this point. // since we don't know the resource IRI yet at this point.
representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences }); representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences });
@ -48,7 +48,7 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
} }
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences }); representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences });
return this.source.setRepresentation(identifier, representation, conditions); return this.source.setRepresentation(identifier, representation, conditions);
} }

View File

@ -1,10 +1,18 @@
import type { Patch } from '../http/representation/Patch'; import type { Patch } from '../http/representation/Patch';
import type { Representation } from '../http/representation/Representation'; import type { Representation } from '../http/representation/Representation';
import type { RepresentationMetadata } from '../http/representation/RepresentationMetadata';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { ResourceSet } from './ResourceSet'; import type { ResourceSet } from './ResourceSet';
/**
* An object containing one property for each resource that was created, updated or deleted
* by this operation. Where the key of the property is the path of the resource (string) and the value is an
* instance of RepresentationMetadata containing extra information about the change of the resource.
*/
export type ChangeMap = Record<string, RepresentationMetadata>;
/** /**
* A ResourceStore represents a collection of resources. * A ResourceStore represents a collection of resources.
* It has been designed such that each of its methods * It has been designed such that each of its methods
@ -38,13 +46,13 @@ export interface ResourceStore extends ResourceSet {
* @param representation - New representation of the resource. * @param representation - New representation of the resource.
* @param conditions - Optional conditions under which to proceed. * @param conditions - Optional conditions under which to proceed.
* *
* @returns Identifiers of resources that were possibly modified. * @returns A {@link ChangeMap}.
*/ */
setRepresentation: ( setRepresentation: (
identifier: ResourceIdentifier, identifier: ResourceIdentifier,
representation: Representation, representation: Representation,
conditions?: Conditions, conditions?: Conditions,
) => Promise<ResourceIdentifier[]>; ) => Promise<ChangeMap>;
/** /**
* Creates a new resource in the container. * Creates a new resource in the container.
@ -52,25 +60,25 @@ export interface ResourceStore extends ResourceSet {
* @param representation - Representation of the new resource * @param representation - Representation of the new resource
* @param conditions - Optional conditions under which to proceed. * @param conditions - Optional conditions under which to proceed.
* *
* @returns The identifier of the newly created resource. * @returns A {@link ChangeMap}.
*/ */
addResource: ( addResource: (
container: ResourceIdentifier, container: ResourceIdentifier,
representation: Representation, representation: Representation,
conditions?: Conditions, conditions?: Conditions,
) => Promise<ResourceIdentifier>; ) => Promise<ChangeMap>;
/** /**
* Deletes a resource. * Deletes a resource.
* @param identifier - Identifier of resource to delete. * @param identifier - Identifier of resource to delete.
* @param conditions - Optional conditions under which to proceed. * @param conditions - Optional conditions under which to proceed.
* *
* @returns Identifiers of resources that were possibly modified. * @returns A {@link ChangeMap}.
*/ */
deleteResource: ( deleteResource: (
identifier: ResourceIdentifier, identifier: ResourceIdentifier,
conditions?: Conditions, conditions?: Conditions,
) => Promise<ResourceIdentifier[]>; ) => Promise<ChangeMap>;
/** /**
* Sets or updates the representation of a resource, * Sets or updates the representation of a resource,
@ -79,11 +87,11 @@ export interface ResourceStore extends ResourceSet {
* @param patch - Description of which parts to update. * @param patch - Description of which parts to update.
* @param conditions - Optional conditions under which to proceed. * @param conditions - Optional conditions under which to proceed.
* *
* @returns Identifiers of resources that were possibly modified. * @returns A {@link ChangeMap}.
*/ */
modifyResource: ( modifyResource: (
identifier: ResourceIdentifier, identifier: ResourceIdentifier,
patch: Patch, patch: Patch,
conditions?: Conditions, conditions?: Conditions,
) => Promise<ResourceIdentifier[]>; ) => Promise<ChangeMap>;
} }

View File

@ -5,7 +5,7 @@ import type { ResourceIdentifier } from '../http/representation/ResourceIdentifi
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
import type { RouterRule } from './routing/RouterRule'; import type { RouterRule } from './routing/RouterRule';
/** /**
@ -31,22 +31,22 @@ export class RoutingResourceStore implements ResourceStore {
} }
public async addResource(container: ResourceIdentifier, representation: Representation, public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> { conditions?: Conditions): Promise<ChangeMap> {
return (await this.getStore(container, representation)).addResource(container, representation, conditions); return (await this.getStore(container, representation)).addResource(container, representation, conditions);
} }
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return (await this.getStore(identifier, representation)).setRepresentation(identifier, representation, conditions); return (await this.getStore(identifier, representation)).setRepresentation(identifier, representation, conditions);
} }
public async deleteResource(identifier: ResourceIdentifier, public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return (await this.getStore(identifier)).deleteResource(identifier, conditions); return (await this.getStore(identifier)).deleteResource(identifier, conditions);
} }
public async modifyResource(identifier: ResourceIdentifier, patch: Patch, public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> { conditions?: Conditions): Promise<ChangeMap> {
return (await this.getStore(identifier)).modifyResource(identifier, patch, conditions); return (await this.getStore(identifier)).modifyResource(identifier, patch, conditions);
} }

View File

@ -1,7 +1,7 @@
import type { Patch } from '../../http/representation/Patch'; import type { Patch } from '../../http/representation/Patch';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { ResourceStore } from '../ResourceStore'; import type { ResourceStore, ChangeMap } from '../ResourceStore';
export type PatchHandlerInput<T extends ResourceStore = ResourceStore> = { export type PatchHandlerInput<T extends ResourceStore = ResourceStore> = {
source: T; source: T;
@ -13,4 +13,4 @@ export type PatchHandlerInput<T extends ResourceStore = ResourceStore> = {
* Executes the given Patch. * Executes the given Patch.
*/ */
export abstract class PatchHandler<T extends ResourceStore = ResourceStore> export abstract class PatchHandler<T extends ResourceStore = ResourceStore>
extends AsyncHandler<PatchHandlerInput<T>, ResourceIdentifier[]> {} extends AsyncHandler<PatchHandlerInput<T>, ChangeMap> {}

View File

@ -1,7 +1,7 @@
import type { Representation } from '../../http/representation/Representation'; import type { Representation } from '../../http/representation/Representation';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil'; import { getLoggerFor } from '../../logging/LogUtil';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import type { ChangeMap } from '../ResourceStore';
import type { PatchHandlerInput } from './PatchHandler'; import type { PatchHandlerInput } from './PatchHandler';
import { PatchHandler } from './PatchHandler'; import { PatchHandler } from './PatchHandler';
import type { RepresentationPatcher } from './RepresentationPatcher'; import type { RepresentationPatcher } from './RepresentationPatcher';
@ -23,7 +23,7 @@ export class RepresentationPatchHandler extends PatchHandler {
this.patcher = patcher; this.patcher = patcher;
} }
public async handle({ source, patch, identifier }: PatchHandlerInput): Promise<ResourceIdentifier[]> { public async handle({ source, patch, identifier }: PatchHandlerInput): Promise<ChangeMap> {
// Get the representation from the store // Get the representation from the store
let representation: Representation | undefined; let representation: Representation | undefined;
try { try {

View File

@ -71,6 +71,12 @@ export const ACL = createUriAndTermNamespace('http://www.w3.org/ns/auth/acl#',
'Control', 'Control',
); );
export const AS = createUriAndTermNamespace('https://www.w3.org/ns/activitystreams#',
'Create',
'Delete',
'Update',
);
export const AUTH = createUriAndTermNamespace('urn:solid:auth:', export const AUTH = createUriAndTermNamespace('urn:solid:auth:',
'userMode', 'userMode',
'publicMode', 'publicMode',
@ -144,6 +150,10 @@ export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms
'InsertDeletePatch', 'InsertDeletePatch',
); );
export const SOLID_AS = createUriAndTermNamespace('http://www.w3.org/ns/solid/activitystreams#',
'Activity',
);
export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:', export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:',
'disallowedMethod', 'disallowedMethod',
'errorResponse', 'errorResponse',

View File

@ -3,12 +3,11 @@ import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation'; import type { Representation } from '../../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import { BasicConditions } from '../../../../src/storage/BasicConditions'; import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore, ChangeMap } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { SOLID_HTTP } from '../../../../src/util/Vocabularies'; import { AS, SOLID_AS, SOLID_HTTP } from '../../../../src/util/Vocabularies';
describe('A PostOperationHandler', (): void => { describe('A PostOperationHandler', (): void => {
let operation: Operation; let operation: Operation;
@ -21,7 +20,16 @@ describe('A PostOperationHandler', (): void => {
body = new BasicRepresentation('', 'text/turtle'); body = new BasicRepresentation('', 'text/turtle');
operation = { method: 'POST', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}}; operation = { method: 'POST', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}};
store = { store = {
addResource: jest.fn(async(): Promise<ResourceIdentifier> => ({ path: 'newPath' } as ResourceIdentifier)), addResource: jest.fn(async(): Promise<ChangeMap> => ({
'https://example.com/parent/newPath': new RepresentationMetadata(
{ path: 'https://example.com/parent/newPath' },
{ [SOLID_AS.terms.Activity.value]: AS.Create },
),
'https://example.com/parent/': new RepresentationMetadata(
{ path: 'https://example.come/parent/' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
})),
} as unknown as ResourceStore; } as unknown as ResourceStore;
handler = new PostOperationHandler(store); handler = new PostOperationHandler(store);
}); });
@ -41,7 +49,7 @@ describe('A PostOperationHandler', (): void => {
const result = await handler.handle({ operation }); const result = await handler.handle({ operation });
expect(result.statusCode).toBe(201); expect(result.statusCode).toBe(201);
expect(result.metadata).toBeInstanceOf(RepresentationMetadata); expect(result.metadata).toBeInstanceOf(RepresentationMetadata);
expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe('newPath'); expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe('https://example.com/parent/newPath');
expect(result.data).toBeUndefined(); expect(result.data).toBeUndefined();
expect(store.addResource).toHaveBeenCalledTimes(1); expect(store.addResource).toHaveBeenCalledTimes(1);
expect(store.addResource).toHaveBeenLastCalledWith(operation.target, body, conditions); expect(store.addResource).toHaveBeenLastCalledWith(operation.target, body, conditions);

View File

@ -23,7 +23,7 @@ import type { Guarded } from '../../../src/util/GuardedStream';
import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy';
import { trimTrailingSlashes } from '../../../src/util/PathUtil'; import { trimTrailingSlashes } from '../../../src/util/PathUtil';
import { guardedStreamFrom } from '../../../src/util/StreamUtil'; import { guardedStreamFrom } from '../../../src/util/StreamUtil';
import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META, DC } from '../../../src/util/Vocabularies'; import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META, DC, SOLID_AS, AS } from '../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory; const { namedNode, quad } = DataFactory;
const GENERATED_PREDICATE = namedNode('generated'); const GENERATED_PREDICATE = namedNode('generated');
@ -265,11 +265,22 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: root }; const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type); representation.metadata.removeAll(RDF.terms.type);
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')), expect(Object.keys(result)).toEqual([
}); root,
await expect(arrayifyStream(accessor.data[result.path].data)).resolves.toEqual([ resourceData ]); expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')),
expect(accessor.data[result.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); ]);
expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
const generatedID = Object.keys(result).find((key): boolean => key !== root)!;
expect(generatedID).toBeDefined();
expect(generatedID).toMatch(new RegExp(`^${root}[^/]+$`, 'u'));
await expect(arrayifyStream(accessor.data[generatedID].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[generatedID]).toBeTruthy();
expect(accessor.data[generatedID].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(result[generatedID].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
}); });
it('can write containers.', async(): Promise<void> => { it('can write containers.', async(): Promise<void> => {
@ -278,16 +289,25 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.contentType = 'text/turtle'; representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ '<> a <http://test.com/coolContainer>.' ]); representation.data = guardedStreamFrom([ '<> a <http://test.com/coolContainer>.' ]);
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
});
expect(accessor.data[result.path]).toBeTruthy();
expect(accessor.data[result.path].metadata.contentType).toBeUndefined();
const { data, metadata } = await store.getRepresentation(result); expect(Object.keys(result)).toEqual([
root,
expect.stringMatching(new RegExp(`^${root}[^/]+?/$`, 'u')),
]);
expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
const generatedID = Object.keys(result).find((key): boolean => key !== root)!;
expect(generatedID).toBeDefined();
expect(generatedID).toMatch(new RegExp(`^${root}[^/]+?/$`, 'u'));
expect(accessor.data[generatedID]).toBeTruthy();
expect(accessor.data[generatedID].metadata.contentType).toBeUndefined();
expect(result[generatedID].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
const { data, metadata } = await store.getRepresentation({ path: generatedID });
const quads = await arrayifyStream<Quad>(data); const quads = await arrayifyStream<Quad>(data);
expect(metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(quads.some((entry): boolean => entry.subject.value === result.path && expect(quads.some((entry): boolean => entry.subject.value === generatedID &&
entry.object.value === 'http://test.com/coolContainer')).toBeTruthy(); entry.object.value === 'http://test.com/coolContainer')).toBeTruthy();
}); });
@ -295,10 +315,14 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: root }; const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type); representation.metadata.removeAll(RDF.terms.type);
representation.metadata.add(SOLID_HTTP.terms.slug, 'newName'); representation.metadata.add(SOLID_HTTP.terms.slug, 'newName');
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({ const result = store.addResource(resourceID, representation);
path: `${root}newName`, await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[`${root}newName`]: expect.any(RepresentationMetadata),
}); });
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[`${root}newName`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
}); });
it('errors on a slug ending on / without Link rel:type Container header.', async(): Promise<void> => { it('errors on a slug ending on / without Link rel:type Container header.', async(): Promise<void> => {
@ -320,10 +344,14 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(RDF.terms.type, LDP.terms.Container); representation.metadata.add(RDF.terms.type, LDP.terms.Container);
representation.metadata.add(SOLID_HTTP.terms.slug, 'newContainer'); representation.metadata.add(SOLID_HTTP.terms.slug, 'newContainer');
representation.data = guardedStreamFrom([ `` ]); representation.data = guardedStreamFrom([ `` ]);
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result).toEqual({ expect(result).toEqual({
path: `${root}newContainer/`, [root]: expect.any(RepresentationMetadata),
[`${root}newContainer/`]: expect.any(RepresentationMetadata),
}); });
expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect(result[`${root}newContainer/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
}); });
it('generates a new URI if adding the slug would create an existing URI.', async(): Promise<void> => { it('generates a new URI if adding the slug would create an existing URI.', async(): Promise<void> => {
@ -331,20 +359,26 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(SOLID_HTTP.terms.slug, 'newName'); representation.metadata.add(SOLID_HTTP.terms.slug, 'newName');
accessor.data[`${root}newName`] = representation; accessor.data[`${root}newName`] = representation;
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result).not.toEqual({ expect(result).not.toEqual(expect.objectContaining({
path: `${root}newName`, [`${root}newName`]: expect.any(RepresentationMetadata),
}); }));
expect(result).not.toEqual({ expect(result).not.toEqual(expect.objectContaining({
path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')), [expect.any(String)]: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
}); }));
}); });
it('generates http://test.com/%26%26 when slug is &%26.', async(): Promise<void> => { it('generates http://test.com/%26%26 when slug is &%26.', async(): Promise<void> => {
const resourceID = { path: root }; const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type); representation.metadata.removeAll(RDF.terms.type);
representation.metadata.add(SOLID_HTTP.terms.slug, '&%26'); representation.metadata.add(SOLID_HTTP.terms.slug, '&%26');
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({ path: `${root}%26%26` }); const result = store.addResource(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[`${root}%26%26`]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[`${root}%26%26`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
}); });
it('errors if the slug contains a slash.', async(): Promise<void> => { it('errors if the slug contains a slash.', async(): Promise<void> => {
@ -408,8 +442,11 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.contentType = 'text/turtle'; representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${root}> a <coolContainer>.` ]); representation.data = guardedStreamFrom([ `<${root}> a <coolContainer>.` ]);
await expect(store.setRepresentation(resourceID, representation)).resolves const result = store.setRepresentation(resourceID, representation);
.toEqual([{ path: `${root}` }]); await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect(mock).toHaveBeenCalledTimes(1); expect(mock).toHaveBeenCalledTimes(1);
expect(mock).toHaveBeenLastCalledWith(resourceID); expect(mock).toHaveBeenLastCalledWith(resourceID);
@ -438,10 +475,13 @@ describe('A DataAccessorBasedStore', (): void => {
it('can write resources.', async(): Promise<void> => { it('can write resources.', async(): Promise<void> => {
const resourceID = { path: `${root}resource` }; const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result = store.setRepresentation(resourceID, representation);
{ path: root }, await expect(result).resolves.toEqual({
{ path: `${root}resource` }, [root]: expect.any(RepresentationMetadata),
]); [resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@ -455,10 +495,13 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type); representation.metadata.removeAll(RDF.terms.type);
representation.metadata.contentType = 'text/turtle'; representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]); representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result = store.setRepresentation(resourceID, representation);
{ path: root }, await expect(result).resolves.toEqual({
{ path: `${root}container/` }, [root]: expect.any(RepresentationMetadata),
]); [resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect(accessor.data[resourceID.path]).toBeTruthy(); expect(accessor.data[resourceID.path]).toBeTruthy();
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined(); expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@ -468,10 +511,13 @@ describe('A DataAccessorBasedStore', (): void => {
it('can overwrite resources which does not update parent metadata.', async(): Promise<void> => { it('can overwrite resources which does not update parent metadata.', async(): Promise<void> => {
const resourceID = { path: `${root}resource` }; const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result = store.setRepresentation(resourceID, representation);
{ path: root }, await expect(result).resolves.toEqual({
{ path: `${root}resource` }, [root]: expect.any(RepresentationMetadata),
]); [resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@ -479,9 +525,11 @@ describe('A DataAccessorBasedStore', (): void => {
// Parent metadata does not get updated if the resource already exists // Parent metadata does not get updated if the resource already exists
representation = new BasicRepresentation('updatedText', 'text/plain'); representation = new BasicRepresentation('updatedText', 'text/plain');
mockDate.mockReturnValue(later); mockDate.mockReturnValue(later);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result2 = store.setRepresentation(resourceID, representation);
{ path: `${root}resource` }, await expect(result2).resolves.toEqual({
]); [resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result2)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ 'updatedText' ]); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ 'updatedText' ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(later.toISOString()); expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(later.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@ -492,10 +540,13 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}resource` }; const resourceID = { path: `${root}resource` };
representation.metadata.add(namedNode('notGen'), 'value'); representation.metadata.add(namedNode('notGen'), 'value');
representation.metadata.add(namedNode('gen'), 'value', SOLID_META.terms.ResponseMetadata); representation.metadata.add(namedNode('gen'), 'value', SOLID_META.terms.ResponseMetadata);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result = store.setRepresentation(resourceID, representation);
{ path: root }, await expect(result).resolves.toEqual({
{ path: `${root}resource` }, [root]: expect.any(RepresentationMetadata),
]); [resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[resourceID.path].metadata.get(namedNode('notGen'))?.value).toBe('value'); expect(accessor.data[resourceID.path].metadata.get(namedNode('notGen'))?.value).toBe('value');
expect(accessor.data[resourceID.path].metadata.get(namedNode('gen'))).toBeUndefined(); expect(accessor.data[resourceID.path].metadata.get(namedNode('gen'))).toBeUndefined();
@ -505,10 +556,13 @@ describe('A DataAccessorBasedStore', (): void => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete accessor.data[root]; delete accessor.data[root];
const resourceID = { path: `${root}resource` }; const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result = store.setRepresentation(resourceID, representation);
{ path: `${root}` }, await expect(result).resolves.toEqual({
{ path: `${root}resource` }, [root]: expect.any(RepresentationMetadata),
]); [resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
}); });
@ -521,10 +575,13 @@ describe('A DataAccessorBasedStore', (): void => {
representation.data = guardedStreamFrom( representation.data = guardedStreamFrom(
[ quad(namedNode(`${root}resource/`), namedNode('a'), namedNode('coolContainer')) ], [ quad(namedNode(`${root}resource/`), namedNode('a'), namedNode('coolContainer')) ],
); );
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result = store.setRepresentation(resourceID, representation);
{ path: `${root}` }, await expect(result).resolves.toEqual({
{ path: `${root}container/` }, [root]: expect.any(RepresentationMetadata),
]); [resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect(accessor.data[resourceID.path]).toBeTruthy(); expect(accessor.data[resourceID.path]).toBeTruthy();
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined(); expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
}); });
@ -544,11 +601,17 @@ describe('A DataAccessorBasedStore', (): void => {
it('creates recursive containers when needed.', async(): Promise<void> => { it('creates recursive containers when needed.', async(): Promise<void> => {
const resourceID = { path: `${root}a/b/resource` }; const resourceID = { path: `${root}a/b/resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result = store.setRepresentation(resourceID, representation);
{ path: `${root}a/` }, await expect(result).resolves.toEqual({
{ path: `${root}a/b/` }, [`${root}`]: expect.any(RepresentationMetadata),
{ path: `${root}a/b/resource` }, [`${root}a/`]: expect.any(RepresentationMetadata),
]); [`${root}a/b/`]: expect.any(RepresentationMetadata),
[`${root}a/b/resource`]: expect.any(RepresentationMetadata),
});
expect((await result)[`${root}`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[`${root}a/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect((await result)[`${root}a/b/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect((await result)[`${root}a/b/resource`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[`${root}a/`].metadata.getAll(RDF.terms.type).map((type): string => type.value)) expect(accessor.data[`${root}a/`].metadata.getAll(RDF.terms.type).map((type): string => type.value))
.toContain(LDP.Container); .toContain(LDP.Container);
@ -573,9 +636,11 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type); representation.metadata.removeAll(RDF.terms.type);
representation.metadata.contentType = 'text/turtle'; representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([]); representation.data = guardedStreamFrom([]);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ const result = store.setRepresentation(resourceID, representation);
{ path: `${root}` }, await expect(result).resolves.toEqual({
]); [root]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect(accessor.data[resourceID.path]).toBeTruthy(); expect(accessor.data[resourceID.path]).toBeTruthy();
expect(Object.keys(accessor.data)).toHaveLength(1); expect(Object.keys(accessor.data)).toHaveLength(1);
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined(); expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
@ -658,51 +723,70 @@ describe('A DataAccessorBasedStore', (): void => {
}); });
it('will delete resources.', async(): Promise<void> => { it('will delete resources.', async(): Promise<void> => {
accessor.data[`${root}resource`] = representation; const resourceID = { path: `${root}resource` };
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([ accessor.data[resourceID.path] = representation;
{ path: `${root}resource` }, await expect(store.deleteResource(resourceID)).resolves.toEqual({
{ path: root }, [root]: expect.any(RepresentationMetadata),
]); [resourceID.path]: expect.any(RepresentationMetadata),
expect(accessor.data[`${root}resource`]).toBeUndefined(); });
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(GENERATED_PREDICATE)).toBeUndefined(); expect(accessor.data[root].metadata.get(GENERATED_PREDICATE)).toBeUndefined();
}); });
it('will delete root non-storage containers.', async(): Promise<void> => { it('will delete root non-storage containers.', async(): Promise<void> => {
accessor.data[root] = new BasicRepresentation(representation.data, containerMetadata); accessor.data[root] = new BasicRepresentation(representation.data, containerMetadata);
await expect(store.deleteResource({ path: root })).resolves.toEqual([ await expect(store.deleteResource({ path: root })).resolves.toEqual(
{ path: root }, { [root]: expect.any(RepresentationMetadata) },
]); );
expect(accessor.data[root]).toBeUndefined(); expect(accessor.data[root]).toBeUndefined();
}); });
it('will delete a root storage auxiliary resource of a non-root container.', async(): Promise<void> => { it('will delete a root storage auxiliary resource of a non-root container.', async(): Promise<void> => {
const resourceID = { path: `${root}container/` };
const auxResourceID = { path: `${root}container/.dummy` };
const storageMetadata = new RepresentationMetadata(representation.metadata); const storageMetadata = new RepresentationMetadata(representation.metadata);
accessor.data[`${root}container/`] = new BasicRepresentation(representation.data, storageMetadata); accessor.data[resourceID.path] = new BasicRepresentation(representation.data, storageMetadata);
accessor.data[`${root}container/.dummy`] = representation; accessor.data[auxResourceID.path] = representation;
auxiliaryStrategy.isRequiredInRoot = jest.fn().mockReturnValue(true); auxiliaryStrategy.isRequiredInRoot = jest.fn().mockReturnValue(true);
await expect(store.deleteResource({ path: `${root}container/.dummy` })).resolves.toEqual([ const result = store.deleteResource(auxResourceID);
{ path: `${root}container/.dummy` }, await expect(result).resolves.toEqual(
{ path: `${root}container/` }, expect.objectContaining({
]); [resourceID.path]: expect.any(RepresentationMetadata),
expect(accessor.data[`${root}container/.dummy`]).toBeUndefined(); [auxResourceID.path]: expect.any(RepresentationMetadata),
}),
);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[auxResourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete);
expect(accessor.data[auxResourceID.path]).toBeUndefined();
}); });
it('will delete related auxiliary resources.', async(): Promise<void> => { it('will delete related auxiliary resources.', async(): Promise<void> => {
accessor.data[`${root}container/`] = representation; const resourceID = { path: `${root}container/` };
accessor.data[`${root}container/.dummy`] = representation; const auxResourceID = { path: `${root}container/.dummy` };
await expect(store.deleteResource({ path: `${root}container/` })).resolves.toEqual([ accessor.data[resourceID.path] = representation;
{ path: `${root}container/` }, accessor.data[auxResourceID.path] = representation;
{ path: `${root}container/.dummy` },
{ path: root }, const result = store.deleteResource(resourceID);
]); await expect(result).resolves.toEqual(
expect(accessor.data[`${root}container/`]).toBeUndefined(); expect.objectContaining({
expect(accessor.data[`${root}container/.dummy`]).toBeUndefined(); [root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
[auxResourceID.path]: expect.any(RepresentationMetadata),
}),
);
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete);
expect((await result)[auxResourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete);
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeUndefined();
}); });
it('will still delete a resource if deleting auxiliary resources causes errors.', async(): Promise<void> => { it('will still delete a resource if deleting auxiliary resources causes errors.', async(): Promise<void> => {
accessor.data[`${root}resource`] = representation; const resourceID = { path: `${root}resource` };
accessor.data[`${root}resource.dummy`] = representation; const auxResourceID = { path: `${root}resource.dummy` };
accessor.data[resourceID.path] = representation;
accessor.data[auxResourceID.path] = representation;
const deleteFn = accessor.deleteResource; const deleteFn = accessor.deleteResource;
accessor.deleteResource = jest.fn(async(identifier: ResourceIdentifier): Promise<void> => { accessor.deleteResource = jest.fn(async(identifier: ResourceIdentifier): Promise<void> => {
if (auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) { if (auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) {
@ -712,12 +796,14 @@ describe('A DataAccessorBasedStore', (): void => {
}); });
const { logger } = store as any; const { logger } = store as any;
logger.error = jest.fn(); logger.error = jest.fn();
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([ const result = store.deleteResource(resourceID);
{ path: `${root}resource` }, expect(Object.keys(await result)).toHaveLength(2);
{ path: root }, await expect(result).resolves.toEqual({
]); [root]: expect.any(RepresentationMetadata),
expect(accessor.data[`${root}resource`]).toBeUndefined(); [resourceID.path]: expect.any(RepresentationMetadata),
expect(accessor.data[`${root}resource.dummy`]).toBeDefined(); });
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeDefined();
expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith( expect(logger.error).toHaveBeenLastCalledWith(
'Error deleting auxiliary resource http://test.com/resource.dummy: auxiliary error!', 'Error deleting auxiliary resource http://test.com/resource.dummy: auxiliary error!',

View File

@ -1,33 +1,86 @@
import type { Patch } from '../../../src/http/representation/Patch'; import type { Patch } from '../../../src/http/representation/Patch';
import type { Representation } from '../../../src/http/representation/Representation'; import type { Representation } from '../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
import { MonitoringStore } from '../../../src/storage/MonitoringStore'; import { MonitoringStore } from '../../../src/storage/MonitoringStore';
import type { ResourceStore } from '../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../src/storage/ResourceStore';
import { AS, SOLID_AS } from '../../../src/util/Vocabularies';
describe('A MonitoringStore', (): void => { describe('A MonitoringStore', (): void => {
let store: MonitoringStore; let store: MonitoringStore;
let source: ResourceStore; let source: ResourceStore;
let changedCallback: () => void; let changedCallback: () => void;
const modified = [ let createdCallback: () => void;
{ path: 'http://example.org/modified/1' }, let updatedCallback: () => void;
{ path: 'http://example.org/modified/2' }, let deletedCallback: () => void;
];
const addResourceReturnMock = {
'http://example.org/foo/bar/new': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Create },
),
'http://example.org/foo/bar/': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
};
const setRepresentationReturnMock = {
'http://example.org/foo/bar/new': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
};
const deleteResourceReturnMock = {
'http://example.org/foo/bar/new': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Delete },
),
'http://example.org/foo/bar/': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
};
const modifyResourceReturnMock = {
'http://example.org/foo/bar/old': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Delete },
),
'http://example.org/foo/bar/new': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Create },
),
'http://example.org/foo/bar/': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
};
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
source = { source = {
getRepresentation: jest.fn(async(): Promise<any> => ({ success: true })), getRepresentation: jest.fn(async(): Promise<any> => ({ success: true })),
addResource: jest.fn(async(): Promise<any> => ({ path: 'http://example.org/foo/bar/new' })), addResource: jest.fn(async(): Promise<any> => addResourceReturnMock),
setRepresentation: jest.fn(async(): Promise<any> => modified), setRepresentation: jest.fn(async(): Promise<any> => setRepresentationReturnMock),
deleteResource: jest.fn(async(): Promise<any> => modified), deleteResource: jest.fn(async(): Promise<any> => deleteResourceReturnMock),
modifyResource: jest.fn(async(): Promise<any> => modified), modifyResource: jest.fn(async(): Promise<any> => modifyResourceReturnMock),
hasResource: jest.fn(async(): Promise<any> => undefined), hasResource: jest.fn(async(): Promise<any> => undefined),
}; };
store = new MonitoringStore(source); store = new MonitoringStore(source);
changedCallback = jest.fn(); changedCallback = jest.fn();
createdCallback = jest.fn();
updatedCallback = jest.fn();
deletedCallback = jest.fn();
store.on('changed', changedCallback); store.on('changed', changedCallback);
store.on(AS.Create, createdCallback);
store.on(AS.Update, updatedCallback);
store.on(AS.Delete, deletedCallback);
}); });
afterEach(async(): Promise<void> => { afterEach(async(): Promise<void> => {
store.removeListener('changed', changedCallback); store.removeListener('changed', changedCallback);
store.removeListener(AS.Create, createdCallback);
store.removeListener(AS.Update, updatedCallback);
store.removeListener(AS.Delete, deletedCallback);
}); });
it('calls getRepresentation directly from the source.', async(): Promise<void> => { it('calls getRepresentation directly from the source.', async(): Promise<void> => {
@ -43,67 +96,89 @@ describe('A MonitoringStore', (): void => {
}); });
it('calls addResource directly from the source.', async(): Promise<void> => { it('calls addResource directly from the source.', async(): Promise<void> => {
await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)).resolves await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)).resolves.toEqual({
.toStrictEqual({ path: 'http://example.org/foo/bar/new' }); 'http://example.org/foo/bar/new': expect.any(RepresentationMetadata),
'http://example.org/foo/bar/': expect.any(RepresentationMetadata),
});
expect(source.addResource).toHaveBeenCalledTimes(1); expect(source.addResource).toHaveBeenCalledTimes(1);
expect(source.addResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); expect(source.addResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined);
}); });
it('fires container and resource change events after addResource.', async(): Promise<void> => { it('fires appropriate events according to the return value of source.addResource.', async(): Promise<void> => {
const result = store.addResource({ path: 'http://example.org/foo/bar/' }, {} as Representation); const result = store.addResource({ path: 'http://example.org/foo/bar/' }, {} as Representation);
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await result; await result;
expect(changedCallback).toHaveBeenCalledTimes(2); expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }); expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Create);
expect(createdCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(deletedCallback).toHaveBeenCalledTimes(0);
}); });
it('calls setRepresentation directly from the source.', async(): Promise<void> => { it('calls setRepresentation directly from the source.', async(): Promise<void> => {
await expect(store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation)) await expect(store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation))
.resolves.toEqual(modified); .resolves.toEqual(setRepresentationReturnMock);
expect(source.setRepresentation).toHaveBeenCalledTimes(1); expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); expect(source.setRepresentation).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined);
}); });
it('fires all modified change events after setRepresentation.', async(): Promise<void> => { it('fires appropriate events according to the return value of source.setRepresentation.', async(): Promise<void> => {
const result = store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation); const result = store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation);
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await result; await result;
expect(changedCallback).toHaveBeenCalledTimes(2); expect(changedCallback).toHaveBeenCalledTimes(1);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' }); expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' }); expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(deletedCallback).toHaveBeenCalledTimes(0);
}); });
it('calls deleteResource directly from the source.', async(): Promise<void> => { it('calls deleteResource directly from the source.', async(): Promise<void> => {
await expect(store.deleteResource({ path: 'http://example.org/foo/bar' })) await expect(store.deleteResource({ path: 'http://example.org/foo/bar' }))
.resolves.toEqual(modified); .resolves.toEqual(deleteResourceReturnMock);
expect(source.deleteResource).toHaveBeenCalledTimes(1); expect(source.deleteResource).toHaveBeenCalledTimes(1);
expect(source.deleteResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, undefined); expect(source.deleteResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, undefined);
}); });
it('fires all modified change events after deleteResource.', async(): Promise<void> => { it('fires appropriate events according to the return value of source.deleteResource.', async(): Promise<void> => {
const result = store.deleteResource({ path: 'http://example.org/foo/bar' }); const result = store.deleteResource({ path: 'http://example.org/foo/bar' });
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await result; await result;
expect(changedCallback).toHaveBeenCalledTimes(2); expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' }); expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' }); expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Delete);
expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(deletedCallback).toHaveBeenCalledTimes(1);
expect(deletedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
}); });
it('calls modifyResource directly from the source.', async(): Promise<void> => { it('calls modifyResource directly from the source.', async(): Promise<void> => {
await expect(store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch)) await expect(store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch))
.resolves.toEqual(modified); .resolves.toEqual(modifyResourceReturnMock);
expect(source.modifyResource).toHaveBeenCalledTimes(1); expect(source.modifyResource).toHaveBeenCalledTimes(1);
expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined);
}); });
it('fires all modified change events after modifyResource.', async(): Promise<void> => { it('fires appropriate events according to the return value of source.modifyResource.', async(): Promise<void> => {
const result = store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch); const result = store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch);
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await result; await result;
expect(changedCallback).toHaveBeenCalledTimes(2); expect(changedCallback).toHaveBeenCalledTimes(3);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' }); expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Create);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' }); expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' }, AS.Delete);
expect(createdCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(deletedCallback).toHaveBeenCalledTimes(1);
expect(deletedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' });
}); });
it('calls hasResource directly from the source.', async(): Promise<void> => { it('calls hasResource directly from the source.', async(): Promise<void> => {
@ -111,4 +186,20 @@ describe('A MonitoringStore', (): void => {
expect(source.hasResource).toHaveBeenCalledTimes(1); expect(source.hasResource).toHaveBeenCalledTimes(1);
expect(source.hasResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }); expect(source.hasResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' });
}); });
it('should not emit an extra event when the Activity is not a valid AS value.', async(): Promise<void> => {
source.addResource = jest.fn(async(): Promise<any> => ({
'http://example.com/path': new RepresentationMetadata(
{ path: 'http://example.com/path' },
{ [SOLID_AS.terms.Activity.value]: 'SomethingRandom' },
),
}));
await store.addResource({ path: 'http://example.org/foo/bar' }, {} as Patch);
expect(changedCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(0);
expect(deletedCallback).toHaveBeenCalledTimes(0);
});
}); });

View File

@ -1,5 +1,6 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Patch } from '../../../../src/http/representation/Patch'; import type { Patch } from '../../../../src/http/representation/Patch';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { PatchHandlerInput } from '../../../../src/storage/patch/PatchHandler'; import type { PatchHandlerInput } from '../../../../src/storage/patch/PatchHandler';
import type { RepresentationPatcher } from '../../../../src/storage/patch/RepresentationPatcher'; import type { RepresentationPatcher } from '../../../../src/storage/patch/RepresentationPatcher';
import { RepresentationPatchHandler } from '../../../../src/storage/patch/RepresentationPatchHandler'; import { RepresentationPatchHandler } from '../../../../src/storage/patch/RepresentationPatchHandler';
@ -20,7 +21,9 @@ describe('A RepresentationPatchHandler', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
source = { source = {
getRepresentation: jest.fn().mockResolvedValue(representation), getRepresentation: jest.fn().mockResolvedValue(representation),
setRepresentation: jest.fn().mockResolvedValue([ identifier ]), setRepresentation: jest.fn().mockResolvedValue({
[identifier.path]: new RepresentationMetadata(identifier),
}),
} as any; } as any;
input = { source, identifier, patch }; input = { source, identifier, patch };
@ -33,7 +36,9 @@ describe('A RepresentationPatchHandler', (): void => {
}); });
it('calls the patcher with the representation from the store.', async(): Promise<void> => { it('calls the patcher with the representation from the store.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toEqual([ identifier ]); await expect(handler.handle(input)).resolves.toEqual({
[identifier.path]: new RepresentationMetadata(identifier),
});
expect(patcher.handleSafe).toHaveBeenCalledTimes(1); expect(patcher.handleSafe).toHaveBeenCalledTimes(1);
expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch, representation }); expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch, representation });
@ -45,7 +50,9 @@ describe('A RepresentationPatchHandler', (): void => {
it('calls the patcher with no representation if there is none.', async(): Promise<void> => { it('calls the patcher with no representation if there is none.', async(): Promise<void> => {
source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
await expect(handler.handle(input)).resolves.toEqual([ identifier ]); await expect(handler.handle(input)).resolves.toEqual({
[identifier.path]: new RepresentationMetadata(identifier),
});
expect(patcher.handleSafe).toHaveBeenCalledTimes(1); expect(patcher.handleSafe).toHaveBeenCalledTimes(1);
expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch }); expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch });