Loading CHANGES.md +17 −0 Original line number Diff line number Diff line Loading @@ -98,6 +98,21 @@ the versioning. - Added `fedify nodeinfo` command, and deprecated `fedify node` command in favor of `fedify nodeinfo`. [[#267], [#331] by Hyeonseo Kim] - Added custom collection dispatchers. [[#310], [#332] by ChanHaeng Lee] - Added `CustomCollectionDispatcher`, `CustomCollectionCounter`, and `CustomCollectionCursor` types for custom collection dispatching. - Added `CustomCollectionCallbackSetters` type for setting custom collection callbacks. - Added `CustomCollectionHandler` class and `handleCustomCollection()` and `handleOrderedCollection()` functions to process custom collections. - Added `setCollectionDispatcher()` and `setOrderedCollectionDispatcher()` methods to the `Federatable` interface. Implemented in `FederationBuilderImpl` class. - Added `getCollectionUri()` method to the `Context` interface. - Added utility types `ConstructorWithTypeId` and `ParamsKeyPath` for custom collection dispatchers. [#168]: https://github.com/fedify-dev/fedify/issues/168 [#197]: https://github.com/fedify-dev/fedify/issues/197 [#248]: https://github.com/fedify-dev/fedify/issues/248 Loading @@ -115,10 +130,12 @@ the versioning. [#298]: https://github.com/fedify-dev/fedify/pull/298 [#304]: https://github.com/fedify-dev/fedify/issues/304 [#309]: https://github.com/fedify-dev/fedify/pull/309 [#310]: https://github.com/fedify-dev/fedify/issues/310 [#311]: https://github.com/fedify-dev/fedify/issues/311 [#321]: https://github.com/fedify-dev/fedify/pull/321 [#328]: https://github.com/fedify-dev/fedify/pull/328 [#331]: https://github.com/fedify-dev/fedify/pull/331 [#332]: https://github.com/fedify-dev/fedify/pull/332 Version 1.7.7 Loading docs/manual/collections.md +313 −0 Original line number Diff line number Diff line Loading @@ -1367,3 +1367,316 @@ ctx.getFeaturedTagsUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") > tags collection actually exists. It only constructs a URI based on the given > identifier, which may respond with `404 Not Found`. Make sure to check > if the identifier is valid before calling the method. Custom collections ------------------ *This API is available since Fedify 1.8.0.* In addition to the built-in collections like outbox, inbox, following, and followers, Fedify allows you to create custom collections for your specific needs. Custom collections can be used to expose any type of ActivityPub objects in a paginated manner. There are two types of custom collections you can create: - **Collection**: An unordered collection of objects - **Ordered Collection**: An ordered collection of objects where the order matters ### Setting up a custom collection To create a custom collection, you use either `setCollectionDispatcher()` for unordered collections or `setOrderedCollectionDispatcher()` for ordered collections. Both methods work similarly to the built-in collection dispatchers. Here's an example of creating a custom collection of bookmarked posts: ~~~~ typescript twoslash import { Article, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; /** * A hypothetical type that represents a bookmarked post. */ interface BookmarkedPost { /** * The ID of the post. */ id: string; /** * The title of the post. */ title: string; /** * The content of the post. */ content: string; } /** * A hypothetical function that returns the bookmarked posts for a user. */ async function getBookmarkedPostsByUserId( userId: string, cursor?: string | null, limit = 10, ): Promise<{ posts: BookmarkedPost[]; nextCursor: string | null }> { return { posts: [], nextCursor: null }; } /** * A hypothetical function that counts bookmarked posts for a user. */ async function getBookmarkCountByUserId(userId: string): Promise<number> { return 0; } // ---cut-before--- federation .setCollectionDispatcher( "bookmarks", // Unique name for this collection Article, // Type of objects in the collection "/users/{identifier}/bookmarks", // URI pattern async (ctx, values, cursor) => { // If a whole collection is requested, return null to use pagination if (cursor == null) return null; // Work with the database to find bookmarked posts const { posts, nextCursor } = await getBookmarkedPostsByUserId( values.identifier, cursor === "" ? null : cursor, 10 ); // Convert posts to Article objects const items = posts.map(post => new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, content: post.content, }) ); return { items, nextCursor }; } ) .setFirstCursor(async (ctx, values) => "") .setCounter(async (ctx, values) => { // Return the total count of bookmarked posts const count = await getBookmarkCountByUserId(values.identifier); return count; }); ~~~~ For ordered collections, simply use `setOrderedCollectionDispatcher()` instead: ~~~~ typescript twoslash import { Article, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; /** * A hypothetical type that represents a bookmarked post. */ interface BookmarkedPost { /** * The ID of the post. */ id: string; /** * The title of the post. */ title: string; /** * The content of the post. */ content: string; } /** * A hypothetical function that returns the bookmarked posts for a user. */ async function getBookmarkedPostsByUserId( userId: string, cursor?: string | null, limit = 10, ): Promise<{ posts: BookmarkedPost[]; nextCursor: string | null }> { return { posts: [], nextCursor: null }; } /** * A hypothetical function that counts bookmarked posts for a user. */ async function getBookmarkCountByUserId(userId: string): Promise<number> { return 0; } // ---cut-before--- federation .setOrderedCollectionDispatcher( "bookmarks", // Unique name for this collection Article, // Type of objects in the collection "/users/{identifier}/bookmarks", // URI pattern async (ctx, values, cursor) => { // Implementation is the same as regular collections if (cursor == null) return null; const { posts, nextCursor } = await getBookmarkedPostsByUserId( values.identifier, cursor === "" ? null : cursor, 10 ); const items = posts.map(post => new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, content: post.content, }) ); return { items, nextCursor }; } ) .setFirstCursor(async (ctx, values) => "") .setCounter(async (ctx, values) => { return await getBookmarkCountByUserId(values.identifier); }); ~~~~ ### Custom collection callbacks Custom collections support the same callback methods as built-in collections: - **`.setCounter()`**: Sets a callback that returns the total number of items in the collection - **`.setFirstCursor()`**: Sets the cursor for the first page of the collection - **`.setLastCursor()`**: Sets the cursor for the last page of the collection - **`.authorize()`**: Sets an authorization predicate to control access to the collection ### Multiple parameters Custom collections can have multiple parameters in their URI patterns: ~~~~ typescript twoslash import { Note, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; /** * A hypothetical function that returns posts by category. */ async function getPostsByCategory( userId: string, category: string, cursor?: string | null, ): Promise<{ posts: any[]; nextCursor: string | null }> { return { posts: [], nextCursor: null }; } // ---cut-before--- federation .setCollectionDispatcher( "category-posts", Note, "/users/{identifier}/categories/{category}/posts", async (ctx, values, cursor) => { // values.identifier and values.category are both available const { posts, nextCursor } = await getPostsByCategory( values.identifier, values.category, cursor === "" ? null : cursor ); const items = posts.map(post => new Note({ id: new URL(`/posts/${post.id}`, ctx.url), content: post.content, })); return { items, nextCursor }; } ) .setFirstCursor(async (ctx, values) => ""); ~~~~ ### Constructing custom collection URIs To construct a custom collection URI, you can use the `Context.getCollectionUri()` method. This method takes the collection name and the parameter values: ~~~~ typescript twoslash import type { Context } from "@fedify/fedify"; const ctx = null as unknown as Context<void>; // ---cut-before--- // For a collection with one parameter: ctx.getCollectionUri("bookmarks", { identifier: "alice" }) // For a collection with multiple parameters: ctx.getCollectionUri("category-posts", { identifier: "alice", category: "technology" }) ~~~~ > [!NOTE] > > The `Context.getCollectionUri()` method does not guarantee that the custom > collection actually exists. It only constructs a URI based on the given > name and parameters, which may respond with `404 Not Found`. Make sure to > check if the parameters are valid before calling the method. ### Authorization You can restrict access to custom collections using the `.authorize()` method: ~~~~ typescript twoslash import { Article, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; /** * A hypothetical function that checks if a user can access another user's bookmarks. */ async function canAccessBookmarks( viewerId: string | null, ownerId: string, ): Promise<boolean> { return false; } /** * A hypothetical function that returns the bookmarked posts for a user. */ async function getBookmarkedPostsByUserId( userId: string, cursor?: string | null, limit = 10, ): Promise<{ posts: any[]; nextCursor: string | null }> { return { posts: [], nextCursor: null }; } async function getActorIdentifier(actorId: URL|null): Promise<string | null> { // Hypothetical function to get the identifier of an actor return ""; } // ---cut-before--- federation .setCollectionDispatcher( "private-bookmarks", Article, "/users/{identifier}/private-bookmarks", async (ctx, values, cursor) => { if (cursor == null) return null; const { posts, nextCursor } = await getBookmarkedPostsByUserId( values.identifier, cursor === "" ? null : cursor ); const items = posts.map(post => new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, content: post.content, }) ); return { items, nextCursor }; } ) .setFirstCursor(async (ctx, values) => "") .authorize(async (ctx, values, signedKey, signedKeyOwner) => { // Only allow access if the viewer is the owner of the bookmarks if (signedKeyOwner == null) return false; const viewerId = await getActorIdentifier(signedKeyOwner.id); return await canAccessBookmarks(viewerId, values.identifier); }); ~~~~ fedify/federation/builder.test.ts +73 −1 Original line number Diff line number Diff line import { assertEquals, assertExists } from "@std/assert"; import { assertEquals, assertExists, assertThrows } from "@std/assert"; import { parseSemVer } from "../nodeinfo/semver.ts"; import type { Protocol } from "../nodeinfo/types.ts"; import { test } from "../testing/mod.ts"; Loading Loading @@ -215,4 +215,76 @@ test("FederationBuilder", async (t) => { assertEquals(personRoute?.values.id, "abc"); }, ); await t.step( "should handle symbol names uniquely in custom collection dispatchers", () => { const builder = createFederationBuilder<string>(); // Create two unnamed symbols const unnamedSymbol1 = Symbol(); const unnamedSymbol2 = Symbol(); const namedSymbol1 = Symbol.for(""); const namedSymbol2 = Symbol.for(""); const strId = String(unnamedSymbol1); const dispatcher = (_ctx: unknown, _params: unknown) => ({ items: [], }); // Test that different unnamed symbols are treated as different builder.setCollectionDispatcher( unnamedSymbol1, Note, "/unnamed-symbol1/{id}", dispatcher, ); // Test that using the same symbol twice throws an error assertThrows( () => { builder.setCollectionDispatcher( unnamedSymbol1, Note, "/unnamed-symbol1-duplicate/{id}", dispatcher, ); }, Error, "Collection dispatcher for Symbol() already set.", ); // Test that using a different symbol works builder.setCollectionDispatcher( unnamedSymbol2, Note, "/unnamed-symbol2/{id}", dispatcher, ); // Test that using same named symbol twice with a different name throws an error builder.setCollectionDispatcher( namedSymbol1, Note, "/named-symbol/{id}", dispatcher, ); assertThrows( () => { builder.setCollectionDispatcher( namedSymbol2, Note, "/named-symbol/{id}", dispatcher, ); }, ); // Test that using string ID stringified from an unnamed symbol works builder.setCollectionDispatcher( strId, Note, "/string-id/{id}", dispatcher, ); }, ); }); fedify/federation/builder.ts +220 −1 Original line number Diff line number Diff line Loading @@ -13,6 +13,9 @@ import type { CollectionCounter, CollectionCursor, CollectionDispatcher, CustomCollectionCounter, CustomCollectionCursor, CustomCollectionDispatcher, InboxErrorHandler, InboxListener, NodeInfoDispatcher, Loading @@ -24,13 +27,19 @@ import type { Context, RequestContext } from "./context.ts"; import type { ActorCallbackSetters, CollectionCallbackSetters, ConstructorWithTypeId, CustomCollectionCallbackSetters, Federation, FederationBuilder, FederationOptions, InboxListenerSetters, ObjectCallbackSetters, ParamsKeyPath, } from "./federation.ts"; import type { CollectionCallbacks } from "./handler.ts"; import type { CollectionCallbacks, CustomCollectionCallbacks, } from "./handler.ts"; import { InboxListenerSet } from "./inbox.ts"; import { Router, RouterError } from "./router.ts"; Loading Loading @@ -91,11 +100,31 @@ export class FederationBuilderImpl<TContextData> inboxListeners?: InboxListenerSet<TContextData>; inboxErrorHandler?: InboxErrorHandler<TContextData>; sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>; collectionTypeIds: Record< string | symbol, ConstructorWithTypeId<Object> >; collectionCallbacks: Record< string | symbol, CustomCollectionCallbacks< Object, Record<string, string>, RequestContext<TContextData>, TContextData > >; /** * Symbol registry for unique identification of unnamed symbols. */ #symbolRegistry = new Map<symbol, string>(); constructor() { this.router = new Router(); this.objectCallbacks = {}; this.objectTypeIds = {}; this.collectionCallbacks = {}; this.collectionTypeIds = {}; } async build( Loading Loading @@ -1167,6 +1196,196 @@ export class FederationBuilderImpl<TContextData> }; return setters; } setCollectionDispatcher< TObject extends Object, TParams extends Record<string, string>, >( name: string | symbol, ...args: [ ConstructorWithTypeId<TObject>, ParamsKeyPath<TParams>, CustomCollectionDispatcher< TObject, TParams, RequestContext<TContextData>, TContextData >, ] ): CustomCollectionCallbackSetters< TParams, RequestContext<TContextData>, TContextData > { return this.#setCustomCollectionDispatcher( name, "collection", ...args, ); } setOrderedCollectionDispatcher< TObject extends Object, TParams extends Record<string, string>, >( name: string | symbol, ...args: [ ConstructorWithTypeId<TObject>, ParamsKeyPath<TParams>, CustomCollectionDispatcher< TObject, TParams, RequestContext<TContextData>, TContextData >, ] ): CustomCollectionCallbackSetters< TParams, RequestContext<TContextData>, TContextData > { return this.#setCustomCollectionDispatcher( name, "orderedCollection", ...args, ); } #setCustomCollectionDispatcher< TObject extends Object, TParams extends Record<string, string>, >( name: string | symbol, collectionType: "collection" | "orderedCollection", itemType: ConstructorWithTypeId<TObject>, path: ParamsKeyPath<TParams>, dispatcher: CustomCollectionDispatcher< TObject, TParams, RequestContext<TContextData>, TContextData >, ): CustomCollectionCallbackSetters< TParams, RequestContext<TContextData>, TContextData > { const strName = String(name); const routeName = `${collectionType}:${this.#uniqueCollectionId(name)}`; if (this.router.has(routeName)) { throw new RouterError( `Collection dispatcher for ${strName} already set.`, ); } // Check if identifier is already used in collectionCallbacks if (this.collectionCallbacks[name] != null) { throw new RouterError( `Collection dispatcher for ${strName} already set.`, ); } const variables = this.router.add(path, routeName); if (variables.size < 1) { throw new RouterError( "Path for collection dispatcher must have at least one variable.", ); } const callbacks: CustomCollectionCallbacks< TObject, TParams, RequestContext<TContextData>, TContextData > = { dispatcher }; // @ts-ignore: TypeScript does not infer the type correctly this.collectionCallbacks[name] = callbacks; this.collectionTypeIds[name] = itemType; const setters: CustomCollectionCallbackSetters< TParams, RequestContext<TContextData>, TContextData > = { setCounter( counter: CustomCollectionCounter< TParams, TContextData >, ) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CustomCollectionCursor< TParams, RequestContext<TContextData>, TContextData >, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CustomCollectionCursor< TParams, RequestContext<TContextData>, TContextData >, ) { callbacks.lastCursor = cursor; return setters; }, authorize( predicate: ObjectAuthorizePredicate< TContextData, keyof TParams & string >, ) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } /** * Get the URL path for a custom collection. * If the collection is not registered, returns null. * @typeParam TParam The parameter names of the requested URL. * @param {string | symbol} name The name of the custom collection. * @param {TParam} values The values to fill in the URL parameters. * @returns {string | null} The URL path for the custom collection, or null if not registered. */ getCollectionPath<TParam extends Record<string, string>>( name: string | symbol, values: TParam, ): string | null { // Check if it's a registered custom collection if (!(name in this.collectionCallbacks)) return null; const routeName = this.#uniqueCollectionId(name); const path = this.router.build(`collection:${routeName}`, values) ?? this.router.build(`orderedCollection:${routeName}`, values); return path; } /** * Converts a name (string or symbol) to a unique string identifier. * For symbols, generates and caches a UUID if not already present. * For strings, returns the string as-is. * @param name The name to convert to a unique identifier * @returns A unique string identifier */ #uniqueCollectionId(name: string | symbol): string { if (typeof name === "string") return name; // Check if symbol already has a unique ID if (!this.#symbolRegistry.has(name)) { // Generate a new UUID for this symbol this.#symbolRegistry.set(name, crypto.randomUUID()); } return this.#symbolRegistry.get(name)!; } } /** Loading fedify/federation/callback.ts +64 −0 Original line number Diff line number Diff line Loading @@ -273,3 +273,67 @@ export type ObjectAuthorizePredicate<TContextData, TParam extends string> = ( signedKey: CryptographicKey | null, signedKeyOwner: Actor | null, ) => boolean | Promise<boolean>; /** * A callback that dispatches a custom collection. * * @typeParam TItem The type of items in the collection. * @typeParam TParams The parameter names of the requested URL. * @typeParam TContext The type of the context. {@link Context} or * {@link RequestContext}. * @typeParam TContextData The context data to pass to the `TContext`. * @typeParam TFilter The type of the filter, if any. * @param context The context. * @param values The parameters of the requested URL. * @param cursor The cursor to start the collection from, or `null` to dispatch * the entire collection without pagination. * @since 1.8.0 */ export type CustomCollectionDispatcher< TItem, TParams extends Record<string, string>, TContext extends Context<TContextData>, TContextData, > = ( context: TContext, values: TParams, cursor: string | null, ) => PageItems<TItem> | null | Promise<PageItems<TItem> | null>; /** * A callback that counts the number of items in a custom collection. * * @typeParam TParams The parameter names of the requested URL. * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The context. * @param values The parameters of the requested URL. * @since 1.8.0 */ export type CustomCollectionCounter< TParams extends Record<string, string>, TContextData, > = ( context: RequestContext<TContextData>, values: TParams, ) => number | bigint | null | Promise<number | bigint | null>; /** * A callback that returns a cursor for a custom collection. * * @typeParam TParams The parameter names of the requested URL. * @typeParam TContext The type of the context. {@link Context} or * {@link RequestContext}. * @typeParam TContextData The context data to pass to the {@link Context}. * @typeParam TFilter The type of the filter, if any. * @param context The context. * @param values The parameters of the requested URL. * @since 1.8.0 */ export type CustomCollectionCursor< TParams extends Record<string, string>, TContext extends Context<TContextData>, TContextData, > = ( context: TContext, values: TParams, ) => string | null | Promise<string | null>; Loading
CHANGES.md +17 −0 Original line number Diff line number Diff line Loading @@ -98,6 +98,21 @@ the versioning. - Added `fedify nodeinfo` command, and deprecated `fedify node` command in favor of `fedify nodeinfo`. [[#267], [#331] by Hyeonseo Kim] - Added custom collection dispatchers. [[#310], [#332] by ChanHaeng Lee] - Added `CustomCollectionDispatcher`, `CustomCollectionCounter`, and `CustomCollectionCursor` types for custom collection dispatching. - Added `CustomCollectionCallbackSetters` type for setting custom collection callbacks. - Added `CustomCollectionHandler` class and `handleCustomCollection()` and `handleOrderedCollection()` functions to process custom collections. - Added `setCollectionDispatcher()` and `setOrderedCollectionDispatcher()` methods to the `Federatable` interface. Implemented in `FederationBuilderImpl` class. - Added `getCollectionUri()` method to the `Context` interface. - Added utility types `ConstructorWithTypeId` and `ParamsKeyPath` for custom collection dispatchers. [#168]: https://github.com/fedify-dev/fedify/issues/168 [#197]: https://github.com/fedify-dev/fedify/issues/197 [#248]: https://github.com/fedify-dev/fedify/issues/248 Loading @@ -115,10 +130,12 @@ the versioning. [#298]: https://github.com/fedify-dev/fedify/pull/298 [#304]: https://github.com/fedify-dev/fedify/issues/304 [#309]: https://github.com/fedify-dev/fedify/pull/309 [#310]: https://github.com/fedify-dev/fedify/issues/310 [#311]: https://github.com/fedify-dev/fedify/issues/311 [#321]: https://github.com/fedify-dev/fedify/pull/321 [#328]: https://github.com/fedify-dev/fedify/pull/328 [#331]: https://github.com/fedify-dev/fedify/pull/331 [#332]: https://github.com/fedify-dev/fedify/pull/332 Version 1.7.7 Loading
docs/manual/collections.md +313 −0 Original line number Diff line number Diff line Loading @@ -1367,3 +1367,316 @@ ctx.getFeaturedTagsUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") > tags collection actually exists. It only constructs a URI based on the given > identifier, which may respond with `404 Not Found`. Make sure to check > if the identifier is valid before calling the method. Custom collections ------------------ *This API is available since Fedify 1.8.0.* In addition to the built-in collections like outbox, inbox, following, and followers, Fedify allows you to create custom collections for your specific needs. Custom collections can be used to expose any type of ActivityPub objects in a paginated manner. There are two types of custom collections you can create: - **Collection**: An unordered collection of objects - **Ordered Collection**: An ordered collection of objects where the order matters ### Setting up a custom collection To create a custom collection, you use either `setCollectionDispatcher()` for unordered collections or `setOrderedCollectionDispatcher()` for ordered collections. Both methods work similarly to the built-in collection dispatchers. Here's an example of creating a custom collection of bookmarked posts: ~~~~ typescript twoslash import { Article, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; /** * A hypothetical type that represents a bookmarked post. */ interface BookmarkedPost { /** * The ID of the post. */ id: string; /** * The title of the post. */ title: string; /** * The content of the post. */ content: string; } /** * A hypothetical function that returns the bookmarked posts for a user. */ async function getBookmarkedPostsByUserId( userId: string, cursor?: string | null, limit = 10, ): Promise<{ posts: BookmarkedPost[]; nextCursor: string | null }> { return { posts: [], nextCursor: null }; } /** * A hypothetical function that counts bookmarked posts for a user. */ async function getBookmarkCountByUserId(userId: string): Promise<number> { return 0; } // ---cut-before--- federation .setCollectionDispatcher( "bookmarks", // Unique name for this collection Article, // Type of objects in the collection "/users/{identifier}/bookmarks", // URI pattern async (ctx, values, cursor) => { // If a whole collection is requested, return null to use pagination if (cursor == null) return null; // Work with the database to find bookmarked posts const { posts, nextCursor } = await getBookmarkedPostsByUserId( values.identifier, cursor === "" ? null : cursor, 10 ); // Convert posts to Article objects const items = posts.map(post => new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, content: post.content, }) ); return { items, nextCursor }; } ) .setFirstCursor(async (ctx, values) => "") .setCounter(async (ctx, values) => { // Return the total count of bookmarked posts const count = await getBookmarkCountByUserId(values.identifier); return count; }); ~~~~ For ordered collections, simply use `setOrderedCollectionDispatcher()` instead: ~~~~ typescript twoslash import { Article, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; /** * A hypothetical type that represents a bookmarked post. */ interface BookmarkedPost { /** * The ID of the post. */ id: string; /** * The title of the post. */ title: string; /** * The content of the post. */ content: string; } /** * A hypothetical function that returns the bookmarked posts for a user. */ async function getBookmarkedPostsByUserId( userId: string, cursor?: string | null, limit = 10, ): Promise<{ posts: BookmarkedPost[]; nextCursor: string | null }> { return { posts: [], nextCursor: null }; } /** * A hypothetical function that counts bookmarked posts for a user. */ async function getBookmarkCountByUserId(userId: string): Promise<number> { return 0; } // ---cut-before--- federation .setOrderedCollectionDispatcher( "bookmarks", // Unique name for this collection Article, // Type of objects in the collection "/users/{identifier}/bookmarks", // URI pattern async (ctx, values, cursor) => { // Implementation is the same as regular collections if (cursor == null) return null; const { posts, nextCursor } = await getBookmarkedPostsByUserId( values.identifier, cursor === "" ? null : cursor, 10 ); const items = posts.map(post => new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, content: post.content, }) ); return { items, nextCursor }; } ) .setFirstCursor(async (ctx, values) => "") .setCounter(async (ctx, values) => { return await getBookmarkCountByUserId(values.identifier); }); ~~~~ ### Custom collection callbacks Custom collections support the same callback methods as built-in collections: - **`.setCounter()`**: Sets a callback that returns the total number of items in the collection - **`.setFirstCursor()`**: Sets the cursor for the first page of the collection - **`.setLastCursor()`**: Sets the cursor for the last page of the collection - **`.authorize()`**: Sets an authorization predicate to control access to the collection ### Multiple parameters Custom collections can have multiple parameters in their URI patterns: ~~~~ typescript twoslash import { Note, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; /** * A hypothetical function that returns posts by category. */ async function getPostsByCategory( userId: string, category: string, cursor?: string | null, ): Promise<{ posts: any[]; nextCursor: string | null }> { return { posts: [], nextCursor: null }; } // ---cut-before--- federation .setCollectionDispatcher( "category-posts", Note, "/users/{identifier}/categories/{category}/posts", async (ctx, values, cursor) => { // values.identifier and values.category are both available const { posts, nextCursor } = await getPostsByCategory( values.identifier, values.category, cursor === "" ? null : cursor ); const items = posts.map(post => new Note({ id: new URL(`/posts/${post.id}`, ctx.url), content: post.content, })); return { items, nextCursor }; } ) .setFirstCursor(async (ctx, values) => ""); ~~~~ ### Constructing custom collection URIs To construct a custom collection URI, you can use the `Context.getCollectionUri()` method. This method takes the collection name and the parameter values: ~~~~ typescript twoslash import type { Context } from "@fedify/fedify"; const ctx = null as unknown as Context<void>; // ---cut-before--- // For a collection with one parameter: ctx.getCollectionUri("bookmarks", { identifier: "alice" }) // For a collection with multiple parameters: ctx.getCollectionUri("category-posts", { identifier: "alice", category: "technology" }) ~~~~ > [!NOTE] > > The `Context.getCollectionUri()` method does not guarantee that the custom > collection actually exists. It only constructs a URI based on the given > name and parameters, which may respond with `404 Not Found`. Make sure to > check if the parameters are valid before calling the method. ### Authorization You can restrict access to custom collections using the `.authorize()` method: ~~~~ typescript twoslash import { Article, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; /** * A hypothetical function that checks if a user can access another user's bookmarks. */ async function canAccessBookmarks( viewerId: string | null, ownerId: string, ): Promise<boolean> { return false; } /** * A hypothetical function that returns the bookmarked posts for a user. */ async function getBookmarkedPostsByUserId( userId: string, cursor?: string | null, limit = 10, ): Promise<{ posts: any[]; nextCursor: string | null }> { return { posts: [], nextCursor: null }; } async function getActorIdentifier(actorId: URL|null): Promise<string | null> { // Hypothetical function to get the identifier of an actor return ""; } // ---cut-before--- federation .setCollectionDispatcher( "private-bookmarks", Article, "/users/{identifier}/private-bookmarks", async (ctx, values, cursor) => { if (cursor == null) return null; const { posts, nextCursor } = await getBookmarkedPostsByUserId( values.identifier, cursor === "" ? null : cursor ); const items = posts.map(post => new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, content: post.content, }) ); return { items, nextCursor }; } ) .setFirstCursor(async (ctx, values) => "") .authorize(async (ctx, values, signedKey, signedKeyOwner) => { // Only allow access if the viewer is the owner of the bookmarks if (signedKeyOwner == null) return false; const viewerId = await getActorIdentifier(signedKeyOwner.id); return await canAccessBookmarks(viewerId, values.identifier); }); ~~~~
fedify/federation/builder.test.ts +73 −1 Original line number Diff line number Diff line import { assertEquals, assertExists } from "@std/assert"; import { assertEquals, assertExists, assertThrows } from "@std/assert"; import { parseSemVer } from "../nodeinfo/semver.ts"; import type { Protocol } from "../nodeinfo/types.ts"; import { test } from "../testing/mod.ts"; Loading Loading @@ -215,4 +215,76 @@ test("FederationBuilder", async (t) => { assertEquals(personRoute?.values.id, "abc"); }, ); await t.step( "should handle symbol names uniquely in custom collection dispatchers", () => { const builder = createFederationBuilder<string>(); // Create two unnamed symbols const unnamedSymbol1 = Symbol(); const unnamedSymbol2 = Symbol(); const namedSymbol1 = Symbol.for(""); const namedSymbol2 = Symbol.for(""); const strId = String(unnamedSymbol1); const dispatcher = (_ctx: unknown, _params: unknown) => ({ items: [], }); // Test that different unnamed symbols are treated as different builder.setCollectionDispatcher( unnamedSymbol1, Note, "/unnamed-symbol1/{id}", dispatcher, ); // Test that using the same symbol twice throws an error assertThrows( () => { builder.setCollectionDispatcher( unnamedSymbol1, Note, "/unnamed-symbol1-duplicate/{id}", dispatcher, ); }, Error, "Collection dispatcher for Symbol() already set.", ); // Test that using a different symbol works builder.setCollectionDispatcher( unnamedSymbol2, Note, "/unnamed-symbol2/{id}", dispatcher, ); // Test that using same named symbol twice with a different name throws an error builder.setCollectionDispatcher( namedSymbol1, Note, "/named-symbol/{id}", dispatcher, ); assertThrows( () => { builder.setCollectionDispatcher( namedSymbol2, Note, "/named-symbol/{id}", dispatcher, ); }, ); // Test that using string ID stringified from an unnamed symbol works builder.setCollectionDispatcher( strId, Note, "/string-id/{id}", dispatcher, ); }, ); });
fedify/federation/builder.ts +220 −1 Original line number Diff line number Diff line Loading @@ -13,6 +13,9 @@ import type { CollectionCounter, CollectionCursor, CollectionDispatcher, CustomCollectionCounter, CustomCollectionCursor, CustomCollectionDispatcher, InboxErrorHandler, InboxListener, NodeInfoDispatcher, Loading @@ -24,13 +27,19 @@ import type { Context, RequestContext } from "./context.ts"; import type { ActorCallbackSetters, CollectionCallbackSetters, ConstructorWithTypeId, CustomCollectionCallbackSetters, Federation, FederationBuilder, FederationOptions, InboxListenerSetters, ObjectCallbackSetters, ParamsKeyPath, } from "./federation.ts"; import type { CollectionCallbacks } from "./handler.ts"; import type { CollectionCallbacks, CustomCollectionCallbacks, } from "./handler.ts"; import { InboxListenerSet } from "./inbox.ts"; import { Router, RouterError } from "./router.ts"; Loading Loading @@ -91,11 +100,31 @@ export class FederationBuilderImpl<TContextData> inboxListeners?: InboxListenerSet<TContextData>; inboxErrorHandler?: InboxErrorHandler<TContextData>; sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>; collectionTypeIds: Record< string | symbol, ConstructorWithTypeId<Object> >; collectionCallbacks: Record< string | symbol, CustomCollectionCallbacks< Object, Record<string, string>, RequestContext<TContextData>, TContextData > >; /** * Symbol registry for unique identification of unnamed symbols. */ #symbolRegistry = new Map<symbol, string>(); constructor() { this.router = new Router(); this.objectCallbacks = {}; this.objectTypeIds = {}; this.collectionCallbacks = {}; this.collectionTypeIds = {}; } async build( Loading Loading @@ -1167,6 +1196,196 @@ export class FederationBuilderImpl<TContextData> }; return setters; } setCollectionDispatcher< TObject extends Object, TParams extends Record<string, string>, >( name: string | symbol, ...args: [ ConstructorWithTypeId<TObject>, ParamsKeyPath<TParams>, CustomCollectionDispatcher< TObject, TParams, RequestContext<TContextData>, TContextData >, ] ): CustomCollectionCallbackSetters< TParams, RequestContext<TContextData>, TContextData > { return this.#setCustomCollectionDispatcher( name, "collection", ...args, ); } setOrderedCollectionDispatcher< TObject extends Object, TParams extends Record<string, string>, >( name: string | symbol, ...args: [ ConstructorWithTypeId<TObject>, ParamsKeyPath<TParams>, CustomCollectionDispatcher< TObject, TParams, RequestContext<TContextData>, TContextData >, ] ): CustomCollectionCallbackSetters< TParams, RequestContext<TContextData>, TContextData > { return this.#setCustomCollectionDispatcher( name, "orderedCollection", ...args, ); } #setCustomCollectionDispatcher< TObject extends Object, TParams extends Record<string, string>, >( name: string | symbol, collectionType: "collection" | "orderedCollection", itemType: ConstructorWithTypeId<TObject>, path: ParamsKeyPath<TParams>, dispatcher: CustomCollectionDispatcher< TObject, TParams, RequestContext<TContextData>, TContextData >, ): CustomCollectionCallbackSetters< TParams, RequestContext<TContextData>, TContextData > { const strName = String(name); const routeName = `${collectionType}:${this.#uniqueCollectionId(name)}`; if (this.router.has(routeName)) { throw new RouterError( `Collection dispatcher for ${strName} already set.`, ); } // Check if identifier is already used in collectionCallbacks if (this.collectionCallbacks[name] != null) { throw new RouterError( `Collection dispatcher for ${strName} already set.`, ); } const variables = this.router.add(path, routeName); if (variables.size < 1) { throw new RouterError( "Path for collection dispatcher must have at least one variable.", ); } const callbacks: CustomCollectionCallbacks< TObject, TParams, RequestContext<TContextData>, TContextData > = { dispatcher }; // @ts-ignore: TypeScript does not infer the type correctly this.collectionCallbacks[name] = callbacks; this.collectionTypeIds[name] = itemType; const setters: CustomCollectionCallbackSetters< TParams, RequestContext<TContextData>, TContextData > = { setCounter( counter: CustomCollectionCounter< TParams, TContextData >, ) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CustomCollectionCursor< TParams, RequestContext<TContextData>, TContextData >, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CustomCollectionCursor< TParams, RequestContext<TContextData>, TContextData >, ) { callbacks.lastCursor = cursor; return setters; }, authorize( predicate: ObjectAuthorizePredicate< TContextData, keyof TParams & string >, ) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } /** * Get the URL path for a custom collection. * If the collection is not registered, returns null. * @typeParam TParam The parameter names of the requested URL. * @param {string | symbol} name The name of the custom collection. * @param {TParam} values The values to fill in the URL parameters. * @returns {string | null} The URL path for the custom collection, or null if not registered. */ getCollectionPath<TParam extends Record<string, string>>( name: string | symbol, values: TParam, ): string | null { // Check if it's a registered custom collection if (!(name in this.collectionCallbacks)) return null; const routeName = this.#uniqueCollectionId(name); const path = this.router.build(`collection:${routeName}`, values) ?? this.router.build(`orderedCollection:${routeName}`, values); return path; } /** * Converts a name (string or symbol) to a unique string identifier. * For symbols, generates and caches a UUID if not already present. * For strings, returns the string as-is. * @param name The name to convert to a unique identifier * @returns A unique string identifier */ #uniqueCollectionId(name: string | symbol): string { if (typeof name === "string") return name; // Check if symbol already has a unique ID if (!this.#symbolRegistry.has(name)) { // Generate a new UUID for this symbol this.#symbolRegistry.set(name, crypto.randomUUID()); } return this.#symbolRegistry.get(name)!; } } /** Loading
fedify/federation/callback.ts +64 −0 Original line number Diff line number Diff line Loading @@ -273,3 +273,67 @@ export type ObjectAuthorizePredicate<TContextData, TParam extends string> = ( signedKey: CryptographicKey | null, signedKeyOwner: Actor | null, ) => boolean | Promise<boolean>; /** * A callback that dispatches a custom collection. * * @typeParam TItem The type of items in the collection. * @typeParam TParams The parameter names of the requested URL. * @typeParam TContext The type of the context. {@link Context} or * {@link RequestContext}. * @typeParam TContextData The context data to pass to the `TContext`. * @typeParam TFilter The type of the filter, if any. * @param context The context. * @param values The parameters of the requested URL. * @param cursor The cursor to start the collection from, or `null` to dispatch * the entire collection without pagination. * @since 1.8.0 */ export type CustomCollectionDispatcher< TItem, TParams extends Record<string, string>, TContext extends Context<TContextData>, TContextData, > = ( context: TContext, values: TParams, cursor: string | null, ) => PageItems<TItem> | null | Promise<PageItems<TItem> | null>; /** * A callback that counts the number of items in a custom collection. * * @typeParam TParams The parameter names of the requested URL. * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The context. * @param values The parameters of the requested URL. * @since 1.8.0 */ export type CustomCollectionCounter< TParams extends Record<string, string>, TContextData, > = ( context: RequestContext<TContextData>, values: TParams, ) => number | bigint | null | Promise<number | bigint | null>; /** * A callback that returns a cursor for a custom collection. * * @typeParam TParams The parameter names of the requested URL. * @typeParam TContext The type of the context. {@link Context} or * {@link RequestContext}. * @typeParam TContextData The context data to pass to the {@link Context}. * @typeParam TFilter The type of the filter, if any. * @param context The context. * @param values The parameters of the requested URL. * @since 1.8.0 */ export type CustomCollectionCursor< TParams extends Record<string, string>, TContext extends Context<TContextData>, TContextData, > = ( context: TContext, values: TParams, ) => string | null | Promise<string | null>;