feat: Support Add/Remove notifications on containers

This commit is contained in:
Joachim Van Herwegen
2023-02-07 13:06:19 +01:00
parent 9e1e65cdb9
commit 134237a80f
17 changed files with 326 additions and 83 deletions

View File

@@ -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));

View File

@@ -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';

View File

@@ -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}.

View File

@@ -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) {

View File

@@ -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;
}
/**

View File

@@ -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(),
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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',
);