Unverified Commit c7ac9ccd authored by Hong Minhee's avatar Hong Minhee
Browse files

Merge pull request #332 from 2chanhaeng/issue#310

parents b696c98e 4291d39b
Loading
Loading
Loading
Loading
+17 −0
Original line number Diff line number Diff line
@@ -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
@@ -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
+313 −0
Original line number Diff line number Diff line
@@ -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);
  });
~~~~
+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";
@@ -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,
      );
    },
  );
});
+220 −1
Original line number Diff line number Diff line
@@ -13,6 +13,9 @@ import type {
  CollectionCounter,
  CollectionCursor,
  CollectionDispatcher,
  CustomCollectionCounter,
  CustomCollectionCursor,
  CustomCollectionDispatcher,
  InboxErrorHandler,
  InboxListener,
  NodeInfoDispatcher,
@@ -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";

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

/**
+64 −0
Original line number Diff line number Diff line
@@ -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