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",
"BasicConditions",
"BasicRepresentation",
"ChangeMap",
"CredentialSet",
"Dict",
"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()`.
* Removed the now obsolete `ParallelFinalizer` util class.
- 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.

View File

@ -29,6 +29,16 @@ and all the entries in `config/storage/backend`.
## MonitoringStore
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
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.

View File

@ -2,8 +2,10 @@ import { getLoggerFor } from '../../logging/LogUtil';
import type { ResourceStore } from '../../storage/ResourceStore';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { AS, SOLID_AS } from '../../util/Vocabularies';
import { CreatedResponseDescription } from '../output/response/CreatedResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import { createResourceIdentifier } from '../representation/ResourceIdentifier';
import type { OperationHandlerInput } 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');
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);
return new CreatedResponseDescription(identifier);
const result = await this.store.addResource(operation.target, operation.body, operation.conditions);
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 {
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 { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
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.
@ -21,22 +21,22 @@ export class BaseResourceStore implements ResourceStore {
}
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
throw new NotImplementedHttpError();
}
public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> {
conditions?: Conditions): Promise<ChangeMap> {
throw new NotImplementedHttpError();
}
public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
throw new NotImplementedHttpError();
}
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
throw new NotImplementedHttpError();
}
}

View File

@ -6,7 +6,7 @@ import type { AuxiliaryStrategy } from '../http/auxiliary/AuxiliaryStrategy';
import { BasicRepresentation } from '../http/representation/BasicRepresentation';
import type { Patch } from '../http/representation/Patch';
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 { getLoggerFor } from '../logging/LogUtil';
import { INTERNAL_QUADS } from '../util/ContentTypes';
@ -39,10 +39,12 @@ import {
SOLID_META,
PREFERRED_PREFIX_TERM,
CONTENT_TYPE_TERM,
SOLID_AS,
AS,
} from '../util/Vocabularies';
import type { DataAccessor } from './accessors/DataAccessor';
import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore';
import type { ResourceStore, ChangeMap } from './ResourceStore';
/**
* 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):
Promise<ResourceIdentifier> {
Promise<ChangeMap> {
this.validateIdentifier(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.
await this.writeData(newID, representation, isContainer, false, false);
return newID;
return this.writeData(newID, representation, isContainer, false, false);
}
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
this.validateIdentifier(identifier);
// Check if the resource already exists
@ -216,7 +216,7 @@ export class DataAccessorBasedStore implements ResourceStore {
}
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<never> {
if (conditions) {
let metadata: RepresentationMetadata | undefined;
try {
@ -233,7 +233,7 @@ export class DataAccessorBasedStore implements ResourceStore {
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);
const metadata = await this.accessor.getMetadata(identifier);
// 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,
// the server MUST also delete the associated auxiliary resources"
// https://solid.github.io/specification/protocol#deleting-resources
const deleted = [ identifier ];
const changes: ChangeMap = {};
if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(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)) {
const container = this.identifierStrategy.getParentContainer(identifier);
deleted.push(container);
changes[container.path] = this.createActivityMetadata(container, AS.Update);
// Update modified date of parent
await this.updateContainerModifiedDate(container);
}
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.
*/
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
// Need to do this before handling container data to have the correct identifier
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
// in container representations derived from the URI path component of PUT and PATCH requests."
// https://solid.github.io/specification/protocol#writing-resources
const modified = [];
let changes: ChangeMap = {};
if (!this.identifierStrategy.isRootContainer(identifier) && !exists) {
const container = this.identifierStrategy.getParentContainer(identifier);
const parent = this.identifierStrategy.getParentContainer(identifier);
if (!createContainers) {
modified.push(container);
changes[parent.path] = this.createActivityMetadata(parent, AS.Update);
} else {
const created = await this.createRecursiveContainers(container);
modified.push(...created.length === 0 ? [ container ] : created);
const createdContainers = await this.createRecursiveContainers(parent);
changes = { ...changes, ...createdContainers };
if (Object.keys(createdContainers).length === 0) {
changes[parent.path] = this.createActivityMetadata(parent, AS.Update);
}
}
// Parent container is also modified
await this.updateContainerModifiedDate(container);
await this.updateContainerModifiedDate(parent);
}
// 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.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.
* @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
try {
const metadata = await this.getNormalizedMetadata(container);
@ -609,7 +617,7 @@ export class DataAccessorBasedStore implements ResourceStore {
if (!isContainerPath(metadata.identifier.value)) {
throw new ForbiddenHttpError(`Creating container ${container.path} conflicts with an existing resource.`);
}
return [];
return {};
} catch (error: unknown) {
if (!NotFoundHttpError.isInstance(error)) {
throw error;
@ -618,9 +626,14 @@ export class DataAccessorBasedStore implements ResourceStore {
// Create the container, starting with its parent
const ancestors = this.identifierStrategy.isRootContainer(container) ?
[] :
{} :
await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(container));
await this.writeData(container, new BasicRepresentation([], container), true, false, false);
return [ ...ancestors, container ];
const changes = await this.writeData(container, new BasicRepresentation([], container), true, false, false);
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 type { AtomicResourceStore } from './AtomicResourceStore';
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,
@ -46,27 +46,27 @@ export class LockingResourceStore implements AtomicResourceStore {
}
public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> {
conditions?: Conditions): Promise<ChangeMap> {
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,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
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,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
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,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
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 { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import { AS, SOLID_AS } from '../util/Vocabularies';
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
@ -29,31 +30,34 @@ export class MonitoringStore<T extends ResourceStore = ResourceStore>
}
public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> {
const identifier = await this.source.addResource(container, representation, conditions);
this.emitChanged([ container, identifier ]);
return identifier;
conditions?: Conditions): Promise<ChangeMap> {
return this.emitChanged(await this.source.addResource(container, representation, conditions));
}
public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
return this.emitChanged(await this.source.deleteResource(identifier, conditions));
}
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));
}
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));
}
private emitChanged(identifiers: ResourceIdentifier[]): typeof identifiers {
for (const identifier of identifiers) {
this.emit('changed', identifier);
}
return identifiers;
private emitChanged(changes: ChangeMap): ChangeMap {
for (const [ key, value ] of Object.entries(changes)) {
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 changes;
}
}

View File

@ -3,7 +3,7 @@ import type { Representation } from '../http/representation/Representation';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
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.
@ -27,22 +27,22 @@ export class PassthroughStore<T extends ResourceStore = ResourceStore> implement
}
public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> {
conditions?: Conditions): Promise<ChangeMap> {
return this.source.addResource(container, representation, conditions);
}
public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
return this.source.deleteResource(identifier, conditions);
}
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
return this.source.modifyResource(identifier, patch, conditions);
}
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
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 { PassthroughStore } from './PassthroughStore';
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.
@ -20,7 +20,7 @@ export class PatchingStore<T extends ResourceStore = ResourceStore> extends Pass
}
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
try {
return await this.source.modifyResource(identifier, patch, conditions);
} catch (error: unknown) {

View File

@ -4,7 +4,7 @@ import type { ResourceIdentifier } from '../http/representation/ResourceIdentifi
import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError';
import type { Conditions } from './Conditions';
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.
@ -16,22 +16,22 @@ export class ReadOnlyStore<T extends ResourceStore = ResourceStore> extends Pass
}
public async addResource(container: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> {
conditions?: Conditions): Promise<ChangeMap> {
throw new ForbiddenHttpError();
}
public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
throw new ForbiddenHttpError();
}
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
throw new ForbiddenHttpError();
}
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
throw new ForbiddenHttpError();
}
}

View File

@ -6,7 +6,7 @@ import type { Conditions } from './Conditions';
import { PassthroughConverter } from './conversion/PassthroughConverter';
import type { RepresentationConverter } from './conversion/RepresentationConverter';
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.
@ -40,7 +40,7 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
}
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,
// since we don't know the resource IRI yet at this point.
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,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences });
return this.source.setRepresentation(identifier, representation, conditions);
}

View File

@ -1,10 +1,18 @@
import type { Patch } from '../http/representation/Patch';
import type { Representation } from '../http/representation/Representation';
import type { RepresentationMetadata } from '../http/representation/RepresentationMetadata';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import type { Conditions } from './Conditions';
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.
* 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 conditions - Optional conditions under which to proceed.
*
* @returns Identifiers of resources that were possibly modified.
* @returns A {@link ChangeMap}.
*/
setRepresentation: (
identifier: ResourceIdentifier,
representation: Representation,
conditions?: Conditions,
) => Promise<ResourceIdentifier[]>;
) => Promise<ChangeMap>;
/**
* Creates a new resource in the container.
@ -52,25 +60,25 @@ export interface ResourceStore extends ResourceSet {
* @param representation - Representation of the new resource
* @param conditions - Optional conditions under which to proceed.
*
* @returns The identifier of the newly created resource.
* @returns A {@link ChangeMap}.
*/
addResource: (
container: ResourceIdentifier,
representation: Representation,
conditions?: Conditions,
) => Promise<ResourceIdentifier>;
) => Promise<ChangeMap>;
/**
* Deletes a resource.
* @param identifier - Identifier of resource to delete.
* @param conditions - Optional conditions under which to proceed.
*
* @returns Identifiers of resources that were possibly modified.
* @returns A {@link ChangeMap}.
*/
deleteResource: (
identifier: ResourceIdentifier,
conditions?: Conditions,
) => Promise<ResourceIdentifier[]>;
) => Promise<ChangeMap>;
/**
* 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 conditions - Optional conditions under which to proceed.
*
* @returns Identifiers of resources that were possibly modified.
* @returns A {@link ChangeMap}.
*/
modifyResource: (
identifier: ResourceIdentifier,
patch: Patch,
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 { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore';
import type { ResourceStore, ChangeMap } from './ResourceStore';
import type { RouterRule } from './routing/RouterRule';
/**
@ -31,22 +31,22 @@ export class RoutingResourceStore implements ResourceStore {
}
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);
}
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);
}
public async deleteResource(identifier: ResourceIdentifier,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
return (await this.getStore(identifier)).deleteResource(identifier, conditions);
}
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
conditions?: Conditions): Promise<ResourceIdentifier[]> {
conditions?: Conditions): Promise<ChangeMap> {
return (await this.getStore(identifier)).modifyResource(identifier, patch, conditions);
}

View File

@ -1,7 +1,7 @@
import type { Patch } from '../../http/representation/Patch';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { ResourceStore } from '../ResourceStore';
import type { ResourceStore, ChangeMap } from '../ResourceStore';
export type PatchHandlerInput<T extends ResourceStore = ResourceStore> = {
source: T;
@ -13,4 +13,4 @@ export type PatchHandlerInput<T extends ResourceStore = ResourceStore> = {
* Executes the given Patch.
*/
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 { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import type { ChangeMap } from '../ResourceStore';
import type { PatchHandlerInput } from './PatchHandler';
import { PatchHandler } from './PatchHandler';
import type { RepresentationPatcher } from './RepresentationPatcher';
@ -23,7 +23,7 @@ export class RepresentationPatchHandler extends PatchHandler {
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
let representation: Representation | undefined;
try {

View File

@ -71,6 +71,12 @@ export const ACL = createUriAndTermNamespace('http://www.w3.org/ns/auth/acl#',
'Control',
);
export const AS = createUriAndTermNamespace('https://www.w3.org/ns/activitystreams#',
'Create',
'Delete',
'Update',
);
export const AUTH = createUriAndTermNamespace('urn:solid:auth:',
'userMode',
'publicMode',
@ -144,6 +150,10 @@ export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms
'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:',
'disallowedMethod',
'errorResponse',

View File

@ -3,12 +3,11 @@ import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
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 { 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 => {
let operation: Operation;
@ -21,7 +20,16 @@ describe('A PostOperationHandler', (): void => {
body = new BasicRepresentation('', 'text/turtle');
operation = { method: 'POST', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}};
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;
handler = new PostOperationHandler(store);
});
@ -41,7 +49,7 @@ describe('A PostOperationHandler', (): void => {
const result = await handler.handle({ operation });
expect(result.statusCode).toBe(201);
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(store.addResource).toHaveBeenCalledTimes(1);
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 { trimTrailingSlashes } from '../../../src/util/PathUtil';
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 GENERATED_PREDICATE = namedNode('generated');
@ -265,11 +265,22 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type);
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')),
});
await expect(arrayifyStream(accessor.data[result.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[result.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
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'));
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> => {
@ -278,16 +289,25 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ '<> a <http://test.com/coolContainer>.' ]);
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);
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();
});
@ -295,10 +315,14 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.add(SOLID_HTTP.terms.slug, 'newName');
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: `${root}newName`,
const result = store.addResource(resourceID, representation);
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> => {
@ -320,10 +344,14 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(RDF.terms.type, LDP.terms.Container);
representation.metadata.add(SOLID_HTTP.terms.slug, 'newContainer');
representation.data = guardedStreamFrom([ `` ]);
const result = await store.addResource(resourceID, representation);
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> => {
@ -331,20 +359,26 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(SOLID_HTTP.terms.slug, 'newName');
accessor.data[`${root}newName`] = representation;
const result = await store.addResource(resourceID, representation);
expect(result).not.toEqual({
path: `${root}newName`,
});
expect(result).not.toEqual({
path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
});
expect(result).not.toEqual(expect.objectContaining({
[`${root}newName`]: expect.any(RepresentationMetadata),
}));
expect(result).not.toEqual(expect.objectContaining({
[expect.any(String)]: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
}));
});
it('generates http://test.com/%26%26 when slug is &%26.', async(): Promise<void> => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type);
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> => {
@ -408,8 +442,11 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${root}> a <coolContainer>.` ]);
await expect(store.setRepresentation(resourceID, representation)).resolves
.toEqual([{ path: `${root}` }]);
const result = store.setRepresentation(resourceID, representation);
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).toHaveBeenLastCalledWith(resourceID);
@ -438,10 +475,13 @@ describe('A DataAccessorBasedStore', (): void => {
it('can write resources.', async(): Promise<void> => {
const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[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 ]);
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());
@ -455,10 +495,13 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}container/` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[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].metadata.contentType).toBeUndefined();
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> => {
const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[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 ]);
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());
@ -479,9 +525,11 @@ describe('A DataAccessorBasedStore', (): void => {
// Parent metadata does not get updated if the resource already exists
representation = new BasicRepresentation('updatedText', 'text/plain');
mockDate.mockReturnValue(later);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}resource` },
]);
const result2 = store.setRepresentation(resourceID, representation);
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' ]);
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());
@ -492,10 +540,13 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}resource` };
representation.metadata.add(namedNode('notGen'), 'value');
representation.metadata.add(namedNode('gen'), 'value', SOLID_META.terms.ResponseMetadata);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[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 ]);
expect(accessor.data[resourceID.path].metadata.get(namedNode('notGen'))?.value).toBe('value');
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
delete accessor.data[root];
const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}` },
{ path: `${root}resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[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 ]);
});
@ -521,10 +575,13 @@ describe('A DataAccessorBasedStore', (): void => {
representation.data = guardedStreamFrom(
[ quad(namedNode(`${root}resource/`), namedNode('a'), namedNode('coolContainer')) ],
);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}` },
{ path: `${root}container/` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[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].metadata.contentType).toBeUndefined();
});
@ -544,11 +601,17 @@ describe('A DataAccessorBasedStore', (): void => {
it('creates recursive containers when needed.', async(): Promise<void> => {
const resourceID = { path: `${root}a/b/resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}a/` },
{ path: `${root}a/b/` },
{ path: `${root}a/b/resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[`${root}`]: expect.any(RepresentationMetadata),
[`${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 ]);
expect(accessor.data[`${root}a/`].metadata.getAll(RDF.terms.type).map((type): string => type.value))
.toContain(LDP.Container);
@ -573,9 +636,11 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([]);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}` },
]);
const result = store.setRepresentation(resourceID, representation);
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(Object.keys(accessor.data)).toHaveLength(1);
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
@ -658,51 +723,70 @@ describe('A DataAccessorBasedStore', (): void => {
});
it('will delete resources.', async(): Promise<void> => {
accessor.data[`${root}resource`] = representation;
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([
{ path: `${root}resource` },
{ path: root },
]);
expect(accessor.data[`${root}resource`]).toBeUndefined();
const resourceID = { path: `${root}resource` };
accessor.data[resourceID.path] = representation;
await expect(store.deleteResource(resourceID)).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
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(GENERATED_PREDICATE)).toBeUndefined();
});
it('will delete root non-storage containers.', async(): Promise<void> => {
accessor.data[root] = new BasicRepresentation(representation.data, containerMetadata);
await expect(store.deleteResource({ path: root })).resolves.toEqual([
{ path: root },
]);
await expect(store.deleteResource({ path: root })).resolves.toEqual(
{ [root]: expect.any(RepresentationMetadata) },
);
expect(accessor.data[root]).toBeUndefined();
});
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);
accessor.data[`${root}container/`] = new BasicRepresentation(representation.data, storageMetadata);
accessor.data[`${root}container/.dummy`] = representation;
accessor.data[resourceID.path] = new BasicRepresentation(representation.data, storageMetadata);
accessor.data[auxResourceID.path] = representation;
auxiliaryStrategy.isRequiredInRoot = jest.fn().mockReturnValue(true);
await expect(store.deleteResource({ path: `${root}container/.dummy` })).resolves.toEqual([
{ path: `${root}container/.dummy` },
{ path: `${root}container/` },
]);
expect(accessor.data[`${root}container/.dummy`]).toBeUndefined();
const result = store.deleteResource(auxResourceID);
await expect(result).resolves.toEqual(
expect.objectContaining({
[resourceID.path]: expect.any(RepresentationMetadata),
[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> => {
accessor.data[`${root}container/`] = representation;
accessor.data[`${root}container/.dummy`] = representation;
await expect(store.deleteResource({ path: `${root}container/` })).resolves.toEqual([
{ path: `${root}container/` },
{ path: `${root}container/.dummy` },
{ path: root },
]);
expect(accessor.data[`${root}container/`]).toBeUndefined();
expect(accessor.data[`${root}container/.dummy`]).toBeUndefined();
const resourceID = { path: `${root}container/` };
const auxResourceID = { path: `${root}container/.dummy` };
accessor.data[resourceID.path] = representation;
accessor.data[auxResourceID.path] = representation;
const result = store.deleteResource(resourceID);
await expect(result).resolves.toEqual(
expect.objectContaining({
[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> => {
accessor.data[`${root}resource`] = representation;
accessor.data[`${root}resource.dummy`] = representation;
const resourceID = { path: `${root}resource` };
const auxResourceID = { path: `${root}resource.dummy` };
accessor.data[resourceID.path] = representation;
accessor.data[auxResourceID.path] = representation;
const deleteFn = accessor.deleteResource;
accessor.deleteResource = jest.fn(async(identifier: ResourceIdentifier): Promise<void> => {
if (auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) {
@ -712,12 +796,14 @@ describe('A DataAccessorBasedStore', (): void => {
});
const { logger } = store as any;
logger.error = jest.fn();
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([
{ path: `${root}resource` },
{ path: root },
]);
expect(accessor.data[`${root}resource`]).toBeUndefined();
expect(accessor.data[`${root}resource.dummy`]).toBeDefined();
const result = store.deleteResource(resourceID);
expect(Object.keys(await result)).toHaveLength(2);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeDefined();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith(
'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 { Representation } from '../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
import { MonitoringStore } from '../../../src/storage/MonitoringStore';
import type { ResourceStore } from '../../../src/storage/ResourceStore';
import { AS, SOLID_AS } from '../../../src/util/Vocabularies';
describe('A MonitoringStore', (): void => {
let store: MonitoringStore;
let source: ResourceStore;
let changedCallback: () => void;
const modified = [
{ path: 'http://example.org/modified/1' },
{ path: 'http://example.org/modified/2' },
];
let createdCallback: () => void;
let updatedCallback: () => void;
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> => {
source = {
getRepresentation: jest.fn(async(): Promise<any> => ({ success: true })),
addResource: jest.fn(async(): Promise<any> => ({ path: 'http://example.org/foo/bar/new' })),
setRepresentation: jest.fn(async(): Promise<any> => modified),
deleteResource: jest.fn(async(): Promise<any> => modified),
modifyResource: jest.fn(async(): Promise<any> => modified),
addResource: jest.fn(async(): Promise<any> => addResourceReturnMock),
setRepresentation: jest.fn(async(): Promise<any> => setRepresentationReturnMock),
deleteResource: jest.fn(async(): Promise<any> => deleteResourceReturnMock),
modifyResource: jest.fn(async(): Promise<any> => modifyResourceReturnMock),
hasResource: jest.fn(async(): Promise<any> => undefined),
};
store = new MonitoringStore(source);
changedCallback = jest.fn();
createdCallback = jest.fn();
updatedCallback = jest.fn();
deletedCallback = jest.fn();
store.on('changed', changedCallback);
store.on(AS.Create, createdCallback);
store.on(AS.Update, updatedCallback);
store.on(AS.Delete, deletedCallback);
});
afterEach(async(): Promise<void> => {
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> => {
@ -43,67 +96,89 @@ describe('A MonitoringStore', (): void => {
});
it('calls addResource directly from the source.', async(): Promise<void> => {
await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)).resolves
.toStrictEqual({ path: 'http://example.org/foo/bar/new' });
await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)).resolves.toEqual({
'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).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);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update);
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> => {
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).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);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' });
expect(changedCallback).toHaveBeenCalledTimes(1);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Update);
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> => {
await expect(store.deleteResource({ path: 'http://example.org/foo/bar' }))
.resolves.toEqual(modified);
.resolves.toEqual(deleteResourceReturnMock);
expect(source.deleteResource).toHaveBeenCalledTimes(1);
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' });
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' });
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/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> => {
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).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);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' });
expect(changedCallback).toHaveBeenCalledTimes(3);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Create);
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> => {
@ -111,4 +186,20 @@ describe('A MonitoringStore', (): void => {
expect(source.hasResource).toHaveBeenCalledTimes(1);
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 type { Patch } from '../../../../src/http/representation/Patch';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { PatchHandlerInput } from '../../../../src/storage/patch/PatchHandler';
import type { RepresentationPatcher } from '../../../../src/storage/patch/RepresentationPatcher';
import { RepresentationPatchHandler } from '../../../../src/storage/patch/RepresentationPatchHandler';
@ -20,7 +21,9 @@ describe('A RepresentationPatchHandler', (): void => {
beforeEach(async(): Promise<void> => {
source = {
getRepresentation: jest.fn().mockResolvedValue(representation),
setRepresentation: jest.fn().mockResolvedValue([ identifier ]),
setRepresentation: jest.fn().mockResolvedValue({
[identifier.path]: new RepresentationMetadata(identifier),
}),
} as any;
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> => {
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).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> => {
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).toHaveBeenLastCalledWith({ identifier, patch });