Skip to content

Commit

Permalink
refactor webhook post published handling into a more modular structure (
Browse files Browse the repository at this point in the history
  • Loading branch information
mike182uk authored Jan 13, 2025
1 parent 0b9f804 commit eb0af84
Show file tree
Hide file tree
Showing 18 changed files with 887 additions and 102 deletions.
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"ignore": []
"ignore": ["**/__snapshots__/**"]
},
"formatter": {
"enabled": true,
Expand Down
40 changes: 40 additions & 0 deletions src/activitypub/activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Activity, Actor } from '@fedify/fedify';

import type { FedifyRequestContext } from '../app';

/**
* Sends an ActivityPub activity to other Fediverse actors
*
* @template TActivity Type of activity to send
* @template TActor Type of actor to utilise when sending an activity
*/
export interface ActivitySender<TActivity, TActor> {
/**
* Send an activity to the followers of an actor
*
* @param activity Activity to send
* @param actor Actor whose followers will receive the activity
*/
sendActivityToActorFollowers(
activity: TActivity,
actor: TActor,
): Promise<void>;
}

/**
* ActivitySender implementation using Fedify's RequestContext
*/
export class FedifyActivitySender implements ActivitySender<Activity, Actor> {
constructor(private readonly fedifyCtx: FedifyRequestContext) {}

async sendActivityToActorFollowers(activity: Activity, actor: Actor) {
await this.fedifyCtx.sendActivity(
{ handle: String(actor.preferredUsername) },
'followers',
activity,
{
preferSharedInbox: true,
},
);
}
}
36 changes: 36 additions & 0 deletions src/activitypub/activity.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';

import type { Activity, Actor } from '@fedify/fedify';

import type { FedifyRequestContext } from '../app';

import { FedifyActivitySender } from './activity';

describe('FedifyActivitySender', () => {
describe('sendActivityToActorFollowers', () => {
it('should send an Activity to the followers of an Actor', async () => {
const handle = 'foo';

const mockActor = {
preferredUsername: handle,
} as Actor;
const mockActivity = {} as Activity;
const mockFedifyCtx = {
sendActivity: vi.fn(),
} as unknown as FedifyRequestContext;

const sender = new FedifyActivitySender(mockFedifyCtx);

await sender.sendActivityToActorFollowers(mockActivity, mockActor);

expect(mockFedifyCtx.sendActivity).toHaveBeenCalledWith(
{ handle },
'followers',
mockActivity,
{
preferSharedInbox: true,
},
);
});
});
});
28 changes: 28 additions & 0 deletions src/activitypub/actor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Actor } from '@fedify/fedify';

import type { FedifyRequestContext } from '../app';

/**
* Resolves an ActivityPub actor
*
* @template TActor Type of actor to resolve
*/
export interface ActorResolver<TActor> {
/**
* Resolve an ActivityPub actor by their handle
*
* @param handle Handle of the actor to resolve
*/
resolveActorByHandle(handle: string): Promise<TActor | null>;
}

/**
* ActorResolver implementation using Fedify's RequestContext
*/
export class FedifyActorResolver implements ActorResolver<Actor> {
constructor(private readonly fedifyCtx: FedifyRequestContext) {}

async resolveActorByHandle(handle: string) {
return this.fedifyCtx.getActor(handle);
}
}
51 changes: 51 additions & 0 deletions src/activitypub/actor.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';

import type { Actor } from '@fedify/fedify';

import type { FedifyRequestContext } from '../app';

import { FedifyActorResolver } from './actor';

describe('FedifyActorResolver', () => {
describe('resolveActorByHandle', () => {
it('should resolve an actor by handle', async () => {
const handle = 'foo';

const mockActor = {} as Actor;

const mockFedifyCtx = {
getActor: vi.fn().mockImplementation((identifier) => {
if (identifier === handle) {
return mockActor;
}

return null;
}),
} as unknown as FedifyRequestContext;

const resolver = new FedifyActorResolver(mockFedifyCtx);

const result = await resolver.resolveActorByHandle(handle);

expect(mockFedifyCtx.getActor).toHaveBeenCalledWith(handle);
expect(result).toBe(mockActor);
});

it('should return null if the actor can not be resolved', async () => {
const handle = 'foo';

const mockFedifyCtx = {
getActor: vi.fn().mockImplementation(() => {
return null;
}),
} as unknown as FedifyRequestContext;

const resolver = new FedifyActorResolver(mockFedifyCtx);

const result = await resolver.resolveActorByHandle(handle);

expect(mockFedifyCtx.getActor).toHaveBeenCalledWith(handle);
expect(result).toBeNull();
});
});
});
5 changes: 5 additions & 0 deletions src/activitypub/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './actor';
export * from './activity';
export * from './object';
export * from './outbox';
export * from './uri';
30 changes: 30 additions & 0 deletions src/activitypub/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Object as FedifyObject, KvStore } from '@fedify/fedify';

/**
* Stores ActivityPub objects
*
* @template TObject Type of the object to store
*/
export interface ObjectStore<TObject> {
/**
* Store an ActivityPub object
*
* @param object Object to store
*/
store(object: TObject): Promise<void>;
}

/**
* ObjectStore implementation using Fedify's KvStore
*/
export class FedifyKvStoreObjectStore implements ObjectStore<FedifyObject> {
constructor(private readonly db: KvStore) {}

async store(object: FedifyObject) {
if (object.id === null) {
throw new Error('Object can not be stored without an ID');
}

await this.db.set([object.id.href], await object.toJsonLd());
}
}
51 changes: 51 additions & 0 deletions src/activitypub/object.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';

import type { Article, KvStore } from '@fedify/fedify';

import { FedifyKvStoreObjectStore } from './object';

describe('FedifyKvStoreObjectStore', () => {
describe('store', () => {
it('should store an object', async () => {
const mockArticleId = new URL(
'https://example.com/article/abc-123',
);
const mockKvStore = {
set: vi.fn(),
} as unknown as KvStore;
const mockArticleJsonLd = {
id: mockArticleId,
type: 'Article',
};
const mockArticle = {
id: mockArticleId,
toJsonLd: vi.fn().mockResolvedValue(mockArticleJsonLd),
} as unknown as Article;

const store = new FedifyKvStoreObjectStore(mockKvStore);

await store.store(mockArticle);

expect(mockKvStore.set).toHaveBeenCalledWith(
[mockArticleId.href],
mockArticleJsonLd,
);
});

it('should throw an error if the object has no ID', async () => {
const mockKvStore = {
set: vi.fn(),
} as unknown as KvStore;
const mockArticle = {
id: null,
toJsonLd: vi.fn().mockResolvedValue({}),
} as unknown as Article;

const store = new FedifyKvStoreObjectStore(mockKvStore);

await expect(store.store(mockArticle)).rejects.toThrow(
'Object can not be stored without an ID',
);
});
});
});
34 changes: 34 additions & 0 deletions src/activitypub/outbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Activity, KvStore } from '@fedify/fedify';

import { addToList } from '../kv-helpers';

/**
* ActivityPub outbox collection
*
* @template TActivity Type of activity in the outbox
*/
export interface Outbox<TActivity> {
/**
* Add an activity to the outbox
*
* @param activity Activity to add
*/
add(activity: TActivity): Promise<void>;
}

/**
* Outbox implementation using Fedify's KvStore
*/
export class FedifyKvStoreOutbox implements Outbox<Activity> {
constructor(private readonly db: KvStore) {}

async add(activity: Activity) {
if (activity.id === null) {
throw new Error(
'Activity can not be added to outbox without an ID',
);
}

await addToList(this.db, ['outbox'], activity.id.href);
}
}
48 changes: 48 additions & 0 deletions src/activitypub/outbox.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';

import type { Activity, KvStore } from '@fedify/fedify';

import * as kvHelpers from '../kv-helpers';

import { FedifyKvStoreOutbox } from './outbox';

vi.mock('../kv-helpers', () => ({
addToList: vi.fn(),
}));

describe('FedifyKvStoreOutbox', () => {
describe('add', () => {
it('should add an activity to the outbox', async () => {
const mockKvStore = {} as KvStore;
const mockActivityId = new URL(
'https://example.com/activity/abc-123',
);
const mockActivity = {
id: mockActivityId,
} as Activity;

const outbox = new FedifyKvStoreOutbox(mockKvStore);

await outbox.add(mockActivity);

expect(kvHelpers.addToList).toHaveBeenCalledWith(
mockKvStore,
['outbox'],
mockActivityId.href,
);
});

it('should throw an error if the activity has no ID', async () => {
const mockKvStore = {} as KvStore;
const mockActivity = {
id: null,
} as Activity;

const outbox = new FedifyKvStoreOutbox(mockKvStore);

await expect(outbox.add(mockActivity)).rejects.toThrow(
'Activity can not be added to outbox without an ID',
);
});
});
});
46 changes: 46 additions & 0 deletions src/activitypub/uri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Object as FedifyObject } from '@fedify/fedify';

import type { FedifyRequestContext } from '../app';

/**
* Builds ActivityPub URIs
*
* @template TObject Type of object to build a URI for
*/
export interface UriBuilder<TObject> {
/**
* Build a URI for an object
*
* @param cls Class of the object to build a URI for
* @param id ID of the object to build a URI for
*/
buildObjectUri(
cls: { new (props: Partial<TObject>): TObject; typeId: URL },
id: string,
): URL;

/**
* Build a URI for an actor's followers collection
*
* @param handle Handle of the actor to build a followers collection URI for
*/
buildFollowersCollectionUri(handle: string): URL;
}

/**
* UriBuilder implementation using Fedify's RequestContext
*/
export class FedifyUriBuilder implements UriBuilder<FedifyObject> {
constructor(private readonly fedifyCtx: FedifyRequestContext) {}

buildObjectUri(
cls: { new (props: Partial<FedifyObject>): FedifyObject; typeId: URL },
id: string,
) {
return this.fedifyCtx.getObjectUri(cls, { id });
}

buildFollowersCollectionUri(handle: string) {
return this.fedifyCtx.getFollowersUri(handle);
}
}
Loading

0 comments on commit eb0af84

Please sign in to comment.