From 7bd07801f251b36821e378071f486d797e101879 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Mon, 26 May 2025 18:41:45 +0200 Subject: [PATCH] feat(api): :sparkles: Add support for batch account data API --- CHANGELOG.md | 1 + api/api/v1/accounts/index.get.test.ts | 52 ++++++++++++++ .../{index.test.ts => index.post.test.ts} | 0 api/api/v1/accounts/index.ts | 70 +++++++++++++++++-- api/api/v1/accounts/relationships/index.ts | 4 +- packages/client/versia/client.ts | 22 ++++++ 6 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 api/api/v1/accounts/index.get.test.ts rename api/api/v1/accounts/{index.test.ts => index.post.test.ts} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c1aea5..04e53ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### API - [x] 🥺 Emoji Reactions are now available! You can react to any note with custom emojis. +- [x] 🔎 Added support for [batch account data API](https://docs.joinmastodon.org/methods/accounts/#index). # `0.8.0` • Federation 2: Electric Boogaloo diff --git a/api/api/v1/accounts/index.get.test.ts b/api/api/v1/accounts/index.get.test.ts new file mode 100644 index 00000000..3b82790a --- /dev/null +++ b/api/api/v1/accounts/index.get.test.ts @@ -0,0 +1,52 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(5); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("/api/v1/accounts", () => { + test("should return accounts", async () => { + await using client = await generateClient(); + + const { data, ok } = await client.getAccounts(users.map((u) => u.id)); + + expect(ok).toBe(true); + expect(data).toEqual( + expect.arrayContaining( + users.map((u) => + expect.objectContaining({ + id: u.id, + username: u.data.username, + acct: u.data.username, + }), + ), + ), + ); + }); + + test("should skip nonexistent accounts", async () => { + await using client = await generateClient(); + + const { data, ok } = await client.getAccounts([ + ...users.map((u) => u.id), + "00000000-0000-0000-0000-000000000000", + ]); + + expect(ok).toBe(true); + expect(data).toEqual( + expect.arrayContaining( + users.map((u) => + expect.objectContaining({ + id: u.id, + username: u.data.username, + acct: u.data.username, + }), + ), + ), + ); + expect(data).toHaveLength(users.length); + }); +}); diff --git a/api/api/v1/accounts/index.test.ts b/api/api/v1/accounts/index.post.test.ts similarity index 100% rename from api/api/v1/accounts/index.test.ts rename to api/api/v1/accounts/index.post.test.ts diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index 2c891f1d..6db5f327 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -1,4 +1,4 @@ -import { zBoolean } from "@versia/client/schemas"; +import { Account as AccountSchema, zBoolean } from "@versia/client/schemas"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; @@ -6,7 +6,7 @@ import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; import ISO6391 from "iso-639-1"; import { z } from "zod"; -import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api"; +import { apiRoute, auth, handleZodError, jsonOrForm, qsQuery } from "@/api"; import { tempmailDomains } from "@/tempmail"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; @@ -42,7 +42,67 @@ const schema = z.object({ }), }); -export default apiRoute((app) => +export default apiRoute((app) => { + app.get( + "/api/v1/accounts", + describeRoute({ + summary: "Get multiple accounts", + description: "View information about multiple profiles.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#index", + }, + tags: ["Accounts"], + responses: { + 200: { + description: + "Account records for the requested confirmed and approved accounts. There can be fewer records than requested if the accounts do not exist or are not confirmed.", + content: { + "application/json": { + schema: resolver(z.array(AccountSchema)), + }, + }, + }, + 422: ApiError.validationFailed().schema, + }, + }), + qsQuery(), + auth({ + auth: false, + scopes: [], + challenge: false, + }), + rateLimit(40), + validator( + "query", + z.object({ + id: z + .array(AccountSchema.shape.id) + .min(1) + .max(40) + .or(AccountSchema.shape.id.transform((v) => [v])) + .openapi({ + description: "The IDs of the Accounts in the database.", + example: [ + "f137ce6f-ff5e-4998-b20f-0361ba9be007", + "8424c654-5d03-4a1b-bec8-4e87db811b5d", + ], + }), + }), + handleZodError, + ), + async (context) => { + const { id: ids } = context.req.valid("query"); + + // Find accounts by IDs + const accounts = await User.fromIds(ids); + + return context.json( + accounts.map((account) => account.toApi()), + 200, + ); + }, + ); + app.post( "/api/v1/accounts", describeRoute({ @@ -360,5 +420,5 @@ export default apiRoute((app) => return context.text("", 200); }, - ), -); + ); +}); diff --git a/api/api/v1/accounts/relationships/index.ts b/api/api/v1/accounts/relationships/index.ts index 4d3dec74..7704361b 100644 --- a/api/api/v1/accounts/relationships/index.ts +++ b/api/api/v1/accounts/relationships/index.ts @@ -71,9 +71,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); // TODO: Implement with_suspended - const { id } = context.req.valid("query"); - - const ids = Array.isArray(id) ? id : [id]; + const { id: ids } = context.req.valid("query"); const relationships = await Relationship.fromOwnerAndSubjects( user, diff --git a/packages/client/versia/client.ts b/packages/client/versia/client.ts index 5f94e96c..70bf94fb 100644 --- a/packages/client/versia/client.ts +++ b/packages/client/versia/client.ts @@ -703,6 +703,28 @@ export class Client extends BaseClient { ); } + /** + * GET /api/v1/accounts + * + * @param ids The account IDs. + * @return An array of accounts. + */ + public getAccounts( + ids: string[], + extra?: RequestInit, + ): Promise[]>> { + const params = new URLSearchParams(); + + for (const id of ids) { + params.append("id[]", id); + } + + return this.get[]>( + `/api/v1/accounts?${params.toString()}`, + extra, + ); + } + /** * GET /api/v1/accounts/id *