mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-29 17:08:52 -07:00
[api] add bulk inventions endpoint
This commit is contained in:
@@ -623,9 +623,9 @@ export async function getMyInventions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A stored record with the client's flat `RRInvention` laid over it — see {@link 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) }
|
return { ...invention, ...toInventionV9(invention) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -923,6 +923,11 @@ export const GenerateGiftRequest = z.object({
|
|||||||
* array: the reference binds `[FromForm] List<string>`, so the client posts
|
* array: the reference binds `[FromForm] List<string>`, so the client posts
|
||||||
* `customAvatarItemIds=a&customAvatarItemIds=b`.
|
* `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({
|
export const BulkCustomAvatarItemsRequest = z.object({
|
||||||
customAvatarItemIds: z
|
customAvatarItemIds: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
|
|||||||
@@ -61,12 +61,14 @@ import {
|
|||||||
toSaveResultV9,
|
toSaveResultV9,
|
||||||
unpublishInvention,
|
unpublishInvention,
|
||||||
updateInvention,
|
updateInvention,
|
||||||
|
withClientInvention,
|
||||||
} from '../inventions-db'
|
} from '../inventions-db'
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
BareBoolean,
|
BareBoolean,
|
||||||
BareInteger,
|
BareInteger,
|
||||||
BulkCustomAvatarItemsRequest,
|
BulkCustomAvatarItemsRequest,
|
||||||
|
InventionBatchRequest,
|
||||||
CreateCustomAvatarItemRequest,
|
CreateCustomAvatarItemRequest,
|
||||||
CustomAvatarItemList,
|
CustomAvatarItemList,
|
||||||
CustomAvatarItemReportRequest,
|
CustomAvatarItemReportRequest,
|
||||||
@@ -122,7 +124,7 @@ import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
|||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
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
|
* 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.
|
* value may itself be a comma-separated list; anything non-numeric is dropped.
|
||||||
*/
|
*/
|
||||||
function inventionIdQuery(c: Context<App>): number[] {
|
function inventionIdQuery(c: Context<App>): number[] {
|
||||||
return (
|
return parseInventionIds(c.req.queries('id') ?? [])
|
||||||
c.req
|
}
|
||||||
.queries('id')
|
|
||||||
?.flatMap((raw) => raw.split(','))
|
/**
|
||||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
* {@link inventionIdQuery} for `POST /api/inventions/v2/batch`, which carries the same
|
||||||
.filter((id) => !Number.isNaN(id)) ?? []
|
* 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 ----------------------------------------------------------
|
// ---- 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 ' +
|
'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.',
|
'see: an unpublished invention comes back only to its creator.',
|
||||||
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
|
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) => {
|
async (c) => c.json(await visibleInventions(c, inventionIdQuery(c)))
|
||||||
const ids = inventionIdQuery(c)
|
)
|
||||||
if (ids.length === 0) return c.json([])
|
|
||||||
|
|
||||||
const playerId = await authedId(c)
|
// The same batch with the ids in a form body (`id=1&id=2`) — what the 2025+ client
|
||||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
// sends, since a room's worth of ids overflows a URL.
|
||||||
return c.json(
|
.post(
|
||||||
inventions.filter(
|
'/api/inventions/v2/batch',
|
||||||
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
|
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`) —
|
// Whether the caller owns every invention in a lineage (`?id=101&id=102&id=103`) —
|
||||||
|
|||||||
@@ -4097,6 +4097,52 @@ describe('public endpoints', () => {
|
|||||||
expect(await batch('')).toEqual([])
|
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 () => {
|
test('GET /api/inventions/v1/fulllineageowner answers for the whole set of ids', async () => {
|
||||||
const save = async (sub: string, name: string): Promise<SavedInvention> => {
|
const save = async (sub: string, name: string): Promise<SavedInvention> => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
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/settags',
|
||||||
'POST /api/inventions/v1/update',
|
'POST /api/inventions/v1/update',
|
||||||
'POST /api/inventions/v1/updateprice',
|
'POST /api/inventions/v1/updateprice',
|
||||||
|
'POST /api/inventions/v2/batch',
|
||||||
'POST /api/inventions/v2/cheer',
|
'POST /api/inventions/v2/cheer',
|
||||||
'POST /api/inventions/v2/delete',
|
'POST /api/inventions/v2/delete',
|
||||||
'POST /api/inventions/v2/unpublish',
|
'POST /api/inventions/v2/unpublish',
|
||||||
|
|||||||
Reference in New Issue
Block a user