mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Support Add/Remove notifications on containers
This commit is contained in:
@@ -9,7 +9,7 @@ import type { ResourceIdentifier } from './ResourceIdentifier';
|
||||
import { isResourceIdentifier } from './ResourceIdentifier';
|
||||
|
||||
export type MetadataIdentifier = ResourceIdentifier | NamedNode | BlankNode;
|
||||
export type MetadataValue = NamedNode | Literal | string | (NamedNode | Literal | string)[];
|
||||
export type MetadataValue = NamedNode | BlankNode | Literal | string | (NamedNode | Literal | BlankNode | string)[];
|
||||
export type MetadataRecord = Record<string, MetadataValue>;
|
||||
export type MetadataGraph = NamedNode | BlankNode | DefaultGraph | string;
|
||||
|
||||
@@ -253,7 +253,7 @@ export class RepresentationMetadata {
|
||||
* Runs the given function on all predicate/object pairs, but only converts the predicate to a named node once.
|
||||
*/
|
||||
private forQuads(predicate: NamedNode, object: MetadataValue,
|
||||
forFn: (pred: NamedNode, obj: NamedNode | Literal) => void): this {
|
||||
forFn: (pred: NamedNode, obj: NamedNode | BlankNode | Literal) => void): this {
|
||||
const objects = Array.isArray(object) ? object : [ object ];
|
||||
for (const obj of objects) {
|
||||
forFn(predicate, toObjectTerm(obj, true));
|
||||
|
||||
@@ -311,6 +311,7 @@ export * from './server/middleware/WebSocketAdvertiser';
|
||||
|
||||
// Server/Notifications/Generate
|
||||
export * from './server/notifications/generate/ActivityNotificationGenerator';
|
||||
export * from './server/notifications/generate/AddRemoveNotificationGenerator';
|
||||
export * from './server/notifications/generate/DeleteNotificationGenerator';
|
||||
export * from './server/notifications/generate/NotificationGenerator';
|
||||
export * from './server/notifications/generate/StateNotificationGenerator';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import type { GenericEventEmitter } from '../../util/GenericEventEmitter';
|
||||
import { createGenericEventEmitterClass } from '../../util/GenericEventEmitter';
|
||||
@@ -8,8 +9,11 @@ import type { AS, VocabularyTerm, VocabularyValue } from '../../util/Vocabularie
|
||||
* Both generic `change` events and ActivityStream-specific events are emitted.
|
||||
*/
|
||||
export type ActivityEmitter =
|
||||
GenericEventEmitter<'changed', (target: ResourceIdentifier, activity: VocabularyTerm<typeof AS>) => void> &
|
||||
GenericEventEmitter<VocabularyValue<typeof AS>, (target: ResourceIdentifier) => void>;
|
||||
GenericEventEmitter<'changed',
|
||||
(target: ResourceIdentifier, activity: VocabularyTerm<typeof AS>, metadata: RepresentationMetadata) => void>
|
||||
&
|
||||
GenericEventEmitter<VocabularyValue<typeof AS>,
|
||||
(target: ResourceIdentifier, metadata: RepresentationMetadata) => void>;
|
||||
|
||||
/**
|
||||
* A class implementation of {@link ActivityEmitter}.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { getLoggerFor } from '../../logging/LogUtil';
|
||||
import { createErrorMessage } from '../../util/errors/ErrorUtil';
|
||||
@@ -28,14 +29,15 @@ export class ListeningActivityHandler extends StaticHandler {
|
||||
this.storage = storage;
|
||||
this.handler = handler;
|
||||
|
||||
emitter.on('changed', (topic, activity): void => {
|
||||
this.emit(topic, activity).catch((error): void => {
|
||||
emitter.on('changed', (topic, activity, metadata): void => {
|
||||
this.emit(topic, activity, metadata).catch((error): void => {
|
||||
this.logger.error(`Something went wrong emitting notifications: ${createErrorMessage(error)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async emit(topic: ResourceIdentifier, activity: VocabularyTerm<typeof AS>): Promise<void> {
|
||||
private async emit(topic: ResourceIdentifier, activity: VocabularyTerm<typeof AS>,
|
||||
metadata: RepresentationMetadata): Promise<void> {
|
||||
const channelIds = await this.storage.getAll(topic);
|
||||
|
||||
for (const id of channelIds) {
|
||||
@@ -57,7 +59,7 @@ export class ListeningActivityHandler extends StaticHandler {
|
||||
|
||||
// No need to wait on this to resolve before going to the next channel.
|
||||
// Prevent failed notification from blocking other notifications.
|
||||
this.handler.handleSafe({ channel, activity, topic })
|
||||
this.handler.handleSafe({ channel, activity, topic, metadata })
|
||||
.then((): Promise<void> => {
|
||||
// Update the `lastEmit` value if the channel has a rate limit
|
||||
if (channel.rate) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||
import type { AS, VocabularyTerm } from '../../util/Vocabularies';
|
||||
@@ -7,6 +8,7 @@ export interface NotificationHandlerInput {
|
||||
topic: ResourceIdentifier;
|
||||
channel: NotificationChannel;
|
||||
activity?: VocabularyTerm<typeof AS>;
|
||||
metadata?: RepresentationMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getETag } from '../../../storage/Conditions';
|
||||
import type { ResourceStore } from '../../../storage/ResourceStore';
|
||||
import { InternalServerError } from '../../../util/errors/InternalServerError';
|
||||
import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError';
|
||||
import { AS } from '../../../util/Vocabularies';
|
||||
import type { Notification } from '../Notification';
|
||||
import { CONTEXT_ACTIVITYSTREAMS, CONTEXT_NOTIFICATION } from '../Notification';
|
||||
import type { NotificationHandlerInput } from '../NotificationHandler';
|
||||
import { NotificationGenerator } from './NotificationGenerator';
|
||||
|
||||
/**
|
||||
* A {@link NotificationGenerator} specifically for Add/Remove notifications.
|
||||
* Creates the notification so the `target` is set to input topic,
|
||||
* and the `object` value is extracted from the provided metadata.
|
||||
*/
|
||||
export class AddRemoveNotificationGenerator extends NotificationGenerator {
|
||||
private readonly store: ResourceStore;
|
||||
|
||||
public constructor(store: ResourceStore) {
|
||||
super();
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
public async canHandle({ activity }: NotificationHandlerInput): Promise<void> {
|
||||
if (!activity || (!activity.equals(AS.terms.Add) && !activity.equals(AS.terms.Remove))) {
|
||||
throw new NotImplementedHttpError(`Only Add/Remove activity updates are supported.`);
|
||||
}
|
||||
}
|
||||
|
||||
public async handle({ activity, topic, metadata }: NotificationHandlerInput): Promise<Notification> {
|
||||
const representation = await this.store.getRepresentation(topic, {});
|
||||
representation.data.destroy();
|
||||
|
||||
const state = getETag(representation.metadata);
|
||||
const objects = metadata?.getAll(AS.terms.object);
|
||||
if (!objects || objects.length === 0) {
|
||||
throw new InternalServerError(`Missing as:object metadata for ${activity?.value} activity on ${topic.path}`);
|
||||
}
|
||||
if (objects.length > 1) {
|
||||
throw new InternalServerError(`Found more than one as:object for ${activity?.value} activity on ${topic.path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
'@context': [
|
||||
CONTEXT_ACTIVITYSTREAMS,
|
||||
CONTEXT_NOTIFICATION,
|
||||
],
|
||||
id: `urn:${Date.now()}:${topic.path}`,
|
||||
type: activity!.value.slice(AS.namespace.length),
|
||||
object: objects[0].value,
|
||||
target: topic.path,
|
||||
state,
|
||||
published: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,8 @@ export class DataAccessorBasedStore implements ResourceStore {
|
||||
|
||||
if (!this.identifierStrategy.isRootContainer(identifier)) {
|
||||
const container = this.identifierStrategy.getParentContainer(identifier);
|
||||
this.addActivityMetadata(changes, container, AS.terms.Update);
|
||||
|
||||
this.addContainerActivity(changes, container, false, identifier);
|
||||
|
||||
// Update modified date of parent
|
||||
await this.updateContainerModifiedDate(container);
|
||||
@@ -424,7 +425,7 @@ export class DataAccessorBasedStore implements ResourceStore {
|
||||
|
||||
const changes: ChangeMap = new IdentifierMap();
|
||||
|
||||
// Tranform representation data to quads and add them to the metadata object
|
||||
// Transform representation data to quads and add them to the metadata object
|
||||
const metadata = new RepresentationMetadata(subjectIdentifier);
|
||||
const quads = await arrayifyStream(representation.data);
|
||||
metadata.addQuads(quads);
|
||||
@@ -482,7 +483,7 @@ export class DataAccessorBasedStore implements ResourceStore {
|
||||
|
||||
// No changes means the parent container exists and will be updated
|
||||
if (changes.size === 0) {
|
||||
this.addActivityMetadata(changes, parent, AS.terms.Update);
|
||||
this.addContainerActivity(changes, parent, true, identifier);
|
||||
}
|
||||
|
||||
// Parent container is also modified
|
||||
@@ -710,4 +711,19 @@ export class DataAccessorBasedStore implements ResourceStore {
|
||||
private addActivityMetadata(map: ChangeMap, id: ResourceIdentifier, activity: NamedNode): void {
|
||||
map.set(id, new RepresentationMetadata(id, { [SOLID_AS.activity]: activity }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates activity metadata specifically for Add/Remove events on a container.
|
||||
* @param map - ChangeMap to update.
|
||||
* @param id - Identifier of the container.
|
||||
* @param add - If there is a resource being added (`true`) or removed (`false`).
|
||||
* @param object - The object that is being added/removed.
|
||||
*/
|
||||
private addContainerActivity(map: ChangeMap, id: ResourceIdentifier, add: boolean, object: ResourceIdentifier): void {
|
||||
const metadata = new RepresentationMetadata({
|
||||
[SOLID_AS.activity]: add ? AS.terms.Add : AS.terms.Remove,
|
||||
[AS.object]: namedNode(object.path),
|
||||
});
|
||||
map.set(id, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Conditions } from './Conditions';
|
||||
import type { ResourceStore, ChangeMap } from './ResourceStore';
|
||||
|
||||
// The ActivityStream terms for which we emit an event
|
||||
const knownActivities = [ AS.terms.Create, AS.terms.Delete, AS.terms.Update ];
|
||||
const knownActivities = [ AS.terms.Add, AS.terms.Create, AS.terms.Delete, AS.terms.Remove, AS.terms.Update ];
|
||||
|
||||
/**
|
||||
* Store that notifies listeners of changes to its source
|
||||
@@ -57,8 +57,8 @@ export class MonitoringStore<T extends ResourceStore = ResourceStore>
|
||||
for (const [ identifier, metadata ] of changes) {
|
||||
const activity = metadata.get(SOLID_AS.terms.activity);
|
||||
if (this.isKnownActivity(activity)) {
|
||||
this.emit('changed', identifier, activity);
|
||||
this.emit(activity.value, identifier);
|
||||
this.emit('changed', identifier, activity, metadata);
|
||||
this.emit(activity.value, identifier, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,8 +145,12 @@ export const ACP = createVocabulary('http://www.w3.org/ns/solid/acp#',
|
||||
);
|
||||
|
||||
export const AS = createVocabulary('https://www.w3.org/ns/activitystreams#',
|
||||
'object',
|
||||
|
||||
'Add',
|
||||
'Create',
|
||||
'Delete',
|
||||
'Remove',
|
||||
'Update',
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user