[api] add bulk inventions endpoint

This commit is contained in:
Devin Zuczek
2026-10-28 22:44:53 -04:00
parent 921be535e4
commit f622b14c77
4 changed files with 124 additions and 22 deletions
+2 -2
View File
@@ -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<SavedInvention, keyof InventionV9Dto> & InventionV9Dto
export type ListedInvention = Omit<SavedInvention, keyof InventionV9Dto> & InventionV9Dto
function withClientInvention(invention: SavedInvention): ListedInvention {
export function withClientInvention(invention: SavedInvention): ListedInvention {
return { ...invention, ...toInventionV9(invention) }
}
+5
View File
@@ -923,6 +923,11 @@ export const GenerateGiftRequest = z.object({
* array: the reference binds `[FromForm] List<string>`, 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())
+70 -20
View File
@@ -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<App>): 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<App>): Promise<number[]> {
const raw = [...(c.req.queries('id') ?? [])]
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
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<App>, ids: number[]): Promise<ListedInvention[]> {
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<App>({ 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`) —
+47
View File
@@ -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<SavedInvention> => {
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',