[api] fix a few 2023 compat issues

This commit is contained in:
Devin Zuczek
2026-09-15 11:22:33 -04:00
parent 3299f05940
commit 71bb29b8c7
14 changed files with 360 additions and 130 deletions
+71 -34
View File
@@ -28,6 +28,7 @@ import {
BannerImageRequest,
BioRequest,
BioResponse,
BulkIdsRequest,
CreateAccountRequest,
CreateAccountResult,
DisplayNameRequest,
@@ -51,6 +52,7 @@ import {
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
import type { Context } from 'hono'
import type { DescribeRouteOptions } from 'hono-openapi'
import type { Account } from '@repo/domain'
import type { App } from './context'
@@ -102,6 +104,69 @@ async function formField(c: Context<App>, name: string): Promise<string> {
return typeof value === 'string' ? value : ''
}
/**
* Read a REPEATED string field from a form-urlencoded / multipart body (`id=1&id=2`).
* `parseBody` keeps only the last value for a duplicated key unless it's told to collect
* them all, which is the whole point here.
*/
async function formFields(c: Context<App>, name: string): Promise<string[]> {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const value = body[name]
const values = Array.isArray(value) ? value : [value]
return values.filter((v): v is string => typeof v === 'string')
}
/**
* OpenAPI spec for the two `/account/bulk` registrations, which differ only in where the
* ids ride.
*/
function bulkRouteSpec(source: 'query' | 'body'): DescribeRouteOptions {
return {
tags: ['Lookup'],
summary: 'Look up many accounts by id',
description: [
'Accepts repeated `id` values (query params and/or a form-urlencoded body) and',
'comma-separated lists. Every requested id appears in the response — ids with no',
'stored row get a synthesized default.',
].join(' '),
...(source === 'query'
? {
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of ids',
schema: { type: 'array', items: { type: 'string' } },
},
],
}
: { requestBody: form(BulkIdsRequest, 'Repeated `id` fields') }),
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
}
}
/**
* Look up many accounts at once. Ids come from repeated `id` query params AND, on the
* POST form, repeated `id` body fields; either may carry a comma-separated list. Deduped
* (first occurrence wins) so a repeated id can't double a row or waste a bound parameter.
*/
async function bulkAccounts(c: Context<App>) {
const raw = [...(c.req.queries('id') ?? []), ...(await formFields(c, 'id'))]
const ids = [
...new Set(
raw
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.trim(), 10))
.filter((n) => !Number.isNaN(n))
),
]
// Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
}
/**
* Project a stored account into the public account DTO — the client's camelCase
* shape, excluding private fields like `email` (surfaced only by /account/me).
@@ -286,40 +351,12 @@ const app = new Hono<App>()
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get(
'/account/bulk',
describeRoute({
tags: ['Lookup'],
summary: 'Look up many accounts by id',
description: [
'Accepts repeated `id` query params and/or comma-separated lists. Every requested',
'id appears in the response — ids with no stored row get a synthesized default.',
].join(' '),
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of ids',
schema: { type: 'array', items: { type: 'string' } },
},
],
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
}),
async (c) => {
// Reads repeated `id` query params; also accept a comma-separated list.
const ids =
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
// Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
}
)
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
// The 2023 client asks for its friends list as a POST with the ids in a
// form-urlencoded body — the same `id=1&id=2&…` it would put in a query string,
// moved off the URL because a few hundred friends overflow it. GET is the same
// lookup and stays for callers (and docs) that prefer it.
.post('/account/bulk', describeRoute(bulkRouteSpec('body')), bulkAccounts)
.get(
'/account/:id/bio',
+10
View File
@@ -212,6 +212,16 @@ export const UsernameRequest = z.object({
),
})
/**
* `POST /account/bulk` form body. `id` repeats once per requested account (the client
* sends its whole friends list this way); each value may also be a comma-separated list.
*/
export const BulkIdsRequest = z.object({
id: z
.union([z.string(), z.string().array()])
.describe('Repeatable; each value may be a comma-separated list of ids'),
})
export const EmailRequest = z.object({
email: z
.string()
@@ -97,6 +97,48 @@ describe('public endpoints', () => {
expect(accounts[1].username).toBe('Player2')
})
test('POST /account/bulk reads the ids from a form body', async () => {
// The 2023 client asks for its friends list as a POST — the ids are in a
// form-urlencoded body, not the query string. GET-only left it a 404.
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'id=1&id=2&id=3',
})
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual([1, 2, 3])
expect(accounts[0].username).toBe('Coach')
})
test('POST /account/bulk answers more ids than D1 will bind at once', async () => {
// D1 caps a statement at 100 bound parameters; a real friends list runs past that,
// and an unchunked `IN (…)` 500s. Requesting a stored id in the LAST chunk proves
// the later statements ran, not just the first.
const ids = [...Array.from({ length: 250 }, (_, i) => 1000 + i), 1]
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: ids.map((id) => `id=${id}`).join('&'),
})
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual(ids)
expect(accounts.at(-1)?.username).toBe('Coach')
})
test('POST /account/bulk returns one entry per id, deduped', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
// Negative ids are real in the client's list and synthesize like any other.
body: 'id=1&id=-1&id=1',
})
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual([1, -1])
expect(accounts[1].username).toBe('Player-1')
})
test('GET /account/search prefix-matches usernames, returns public DTOs', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/search?name=coa`)
expect(res.status).toBe(200)
@@ -652,6 +694,7 @@ describe('auth-gated endpoints', () => {
'GET /accountprivacysettings/{id}',
'GET /emojiConfig/whitelistedEmojis',
'GET /parentalcontrol/me',
'POST /account/bulk',
'POST /account/create',
'POST /account/me/email',
'POST /account/me/phone',