diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index e4d1ffc..a2cbac2 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -623,9 +623,9 @@ export async function getMyInventions( } /** A stored record with the client's flat `RRInvention` laid over it — see {@link getMyInventions}. */ -type ListedInvention = Omit & InventionV9Dto +export type ListedInvention = Omit & InventionV9Dto -function withClientInvention(invention: SavedInvention): ListedInvention { +export function withClientInvention(invention: SavedInvention): ListedInvention { return { ...invention, ...toInventionV9(invention) } } diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index bd05f12..d447b79 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -923,6 +923,11 @@ export const GenerateGiftRequest = z.object({ * array: the reference binds `[FromForm] List`, so the client posts * `customAvatarItemIds=a&customAvatarItemIds=b`. */ +/** `POST /api/inventions/v2/batch` form body — the GET's repeated `id`, posted. */ +export const InventionBatchRequest = z.object({ + id: z.array(z.string()).describe('The invention ids to resolve; repeat the field once per id'), +}) + export const BulkCustomAvatarItemsRequest = z.object({ customAvatarItemIds: z .array(z.string()) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index f303fb2..505b8dc 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -61,12 +61,14 @@ import { toSaveResultV9, unpublishInvention, updateInvention, + withClientInvention, } from '../inventions-db' import { AUTHED, BareBoolean, BareInteger, BulkCustomAvatarItemsRequest, + InventionBatchRequest, CreateCustomAvatarItemRequest, CustomAvatarItemList, CustomAvatarItemReportRequest, @@ -122,7 +124,7 @@ import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit' import type { Context } from 'hono' import type { App } from '../context' -import type { InventionTag, SavedInvention } from '../inventions-db' +import type { InventionTag, ListedInvention, SavedInvention } from '../inventions-db' /** * The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers @@ -382,13 +384,45 @@ async function createInventionFromBody( * value may itself be a comma-separated list; anything non-numeric is dropped. */ function inventionIdQuery(c: Context): number[] { - return ( - c.req - .queries('id') - ?.flatMap((raw) => raw.split(',')) - .map((raw) => Number.parseInt(raw.trim(), 10)) - .filter((id) => !Number.isNaN(id)) ?? [] - ) + return parseInventionIds(c.req.queries('id') ?? []) +} + +/** + * {@link inventionIdQuery} for `POST /api/inventions/v2/batch`, which carries the same + * repeated `id` in a form body instead (the query string is read too, so either works). + */ +async function inventionIdForm(c: Context): Promise { + const raw = [...(c.req.queries('id') ?? [])] + const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record) + const posted = body.id + for (const value of Array.isArray(posted) ? posted : [posted]) { + if (typeof value === 'string') raw.push(value) + } + return parseInventionIds(raw) +} + +/** + * The client asks for official-game inventions by their 64-bit ids, which a JS number + * can't hold exactly; those never match a local (sequential) id and are simply dropped. + */ +function parseInventionIds(raw: string[]): number[] { + return raw + .flatMap((value) => value.split(',')) + .map((value) => Number.parseInt(value.trim(), 10)) + .filter((id) => !Number.isNaN(id)) +} + +/** + * The inventions `ids` names that the caller may see — the body of both batch routes — + * with the 2025 client's flat `RRInvention` laid over the stored record, as `v2/mine` serves. + */ +async function visibleInventions(c: Context, ids: number[]): Promise { + if (ids.length === 0) return [] + const playerId = await authedId(c) + const inventions = await getInventionsByIds(c.env.DB, ids) + return inventions + .filter((i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)) + .map(withClientInvention) } // ---- Avatar gifts ---------------------------------------------------------- @@ -1369,20 +1403,36 @@ export const avatarRoutes = new Hono({ strict: false }) 'and an empty request is an empty list. Auth is optional and only widens what you ' + 'see: an unpublished invention comes back only to its creator.', parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')], - responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') }, + responses: { + 200: json( + InventionDto.extend(InventionV9Dto.shape).array(), + 'The inventions the caller may see' + ), + }, }), - async (c) => { - const ids = inventionIdQuery(c) - if (ids.length === 0) return c.json([]) + async (c) => c.json(await visibleInventions(c, inventionIdQuery(c))) + ) - const playerId = await authedId(c) - const inventions = await getInventionsByIds(c.env.DB, ids) - return c.json( - inventions.filter( - (i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId) - ) - ) - } + // The same batch with the ids in a form body (`id=1&id=2`) — what the 2025+ client + // sends, since a room's worth of ids overflows a URL. + .post( + '/api/inventions/v2/batch', + describeRoute({ + tags: ['Inventions'], + summary: 'Inventions by id, in bulk (form body)', + description: + '`GET /api/inventions/v2/batch` with the repeated `id` posted as a form instead of ' + + 'the query string. Unknown ids are dropped, including official-game ids that have ' + + 'no local invention; auth is optional and only adds the caller’s unpublished ones.', + requestBody: form(InventionBatchRequest, 'The invention ids to resolve'), + responses: { + 200: json( + InventionDto.extend(InventionV9Dto.shape).array(), + 'The inventions the caller may see' + ), + }, + }), + async (c) => c.json(await visibleInventions(c, await inventionIdForm(c))) ) // Whether the caller owns every invention in a lineage (`?id=101&id=102&id=103`) — diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 7785f59..746c8a2 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4097,6 +4097,52 @@ describe('public endpoints', () => { expect(await batch('')).toEqual([]) }) + test('POST /api/inventions/v2/batch reads the ids from a form body', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('5567')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Batch Form', inventionDataFilename: 'a.inv' }), + }) + const { Invention } = (await save.json()) as InventionSaveResult + await exports.default.fetch( + `${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`, + { headers: await bearer('5567') } + ) + + // Official-game 64-bit ids have no local invention and are dropped. + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `id=4984136863891873049&id=${Invention.InventionId}&id=9181913027670897186`, + }) + expect(res.status).toBe(200) + // The 2025 client's flat `RRInvention` keys, laid over the stored record. + expect(await res.json()).toMatchObject([ + { + InventionId: Invention.InventionId, + UgcVersion: 1, + LatestVersionNumber: Invention.CurrentVersionNumber, + Accessibility: 1, + ForceCannotPublish: false, + IsRecRoomApproved: false, + DisplayMetadataJson: null, + }, + ]) + + const empty = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: '', + }) + expect(await empty.json()).toEqual([]) + + await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, { + method: 'POST', + headers: { ...(await bearer('5567')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: Invention.InventionId }), + }) + }) + test('GET /api/inventions/v1/fulllineageowner answers for the whole set of ids', async () => { const save = async (sub: string, name: string): Promise => { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { @@ -10689,6 +10735,7 @@ describe('openapi', () => { 'POST /api/inventions/v1/settags', 'POST /api/inventions/v1/update', 'POST /api/inventions/v1/updateprice', + 'POST /api/inventions/v2/batch', 'POST /api/inventions/v2/cheer', 'POST /api/inventions/v2/delete', 'POST /api/inventions/v2/unpublish',