[openapi] define missing routes

This commit is contained in:
Devin Zuczek
2026-08-25 12:41:31 -04:00
parent 3b5e47c39d
commit 6a9a1a95b5
9 changed files with 650 additions and 111 deletions
+4 -1
View File
@@ -20,7 +20,10 @@
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+317 -98
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
@@ -10,10 +11,30 @@ import {
getRecentlyUpdatedRooms,
getVisitedRooms,
} from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { resolveCuratedList, serializeCuratedList } from './curated-lists'
import {
ALGORITHMIC_LIST_PARAM,
ALGORITHMIC_TYPE_PARAM,
AlgorithmicList,
AUTHED,
ContextualFeaturesAck,
CREATOR_ACCOUNT_ID_PARAM,
CuratedListRead,
CuratedListSaved,
CuratedListsBulk,
form,
ITEM_ID_PARAM,
json,
LIST_IDS_PARAM,
LIST_NAME_PARAM,
LIST_TYPE_PARAM,
SAVE_LIST_NAME_PARAM,
SaveItemBody,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { CuratedList, Room } from '@repo/domain'
@@ -305,29 +326,62 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', async (c) => {
return c.text('hello, world!')
})
// Root health check.
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the lists worker. No auth; the body is plain text.',
responses: {
200: {
description: 'Service is up',
content: { 'text/plain': { schema: { type: 'string', example: 'hello, world!' } } },
},
},
}),
async (c) => {
return c.text('hello, world!')
}
)
// Bulk curated-list lookup — the client asks for a set of lists by repeating `?id=`.
// Nothing curates lists here yet, so this serves one canned list: `ItemIds` are strings
// (not numbers) and `Description` may be null, but `ImageName` has to be a string — the
// client's parser reads it straight into a string field. A 404 shows as a failed load
// instead, so an unknown id still answers 200.
.get('/curatedlists/bulk', async (c) => {
return c.json([
{
ListId: 17859340,
CreatorAccountId: 1,
Name: 'My List',
Description: null,
ImageName: '',
Type: ListEntityType.Rooms,
ItemIds: ['123', '456'],
CreatedAt: '2025-07-18T00:00:00Z',
},
])
})
.get(
'/curatedlists/bulk',
describeRoute({
tags: ['Lists', '2025'],
summary: 'Curated lists by id',
description: [
'A set of curated lists, asked for by repeating `?id=`. Nothing curates lists here yet,',
'so this serves ONE canned list whatever is asked for — an unknown id included, because',
'a 404 shows as a row that failed to load rather than one the client hides.',
'',
'The canned list is shaped the way the client parses one: `ItemIds` are strings rather',
'than numbers, `Description` may be null, and `ImageName` has to be a string — the',
'client reads it straight into a string field.',
].join('\n'),
parameters: [LIST_IDS_PARAM],
responses: { 200: json(CuratedListsBulk, 'The canned list, as a one-element array') },
}),
async (c) => {
return c.json([
{
ListId: 17859340,
CreatorAccountId: 1,
Name: 'My List',
Description: null,
ImageName: '',
Type: ListEntityType.Rooms,
ItemIds: ['123', '456'],
CreatedAt: '2025-07-18T00:00:00Z',
},
])
}
)
// One curated list (`GET /curatedlists?creatorAccountId=&type=&name=`). The client reads
// back ONE list object — not a collection — and asks for two different things through the
@@ -350,21 +404,57 @@ const app = new Hono<App>()
// page's heading, which reads as real content rather than as a missing list. The
// exceptions are the client's own reserved playlists and a request naming no list at all;
// both are real answers, not misses (see `resolveCuratedList`).
.get('/curatedlists', async (c) => {
const creatorAccountId = c.req.query('creatorAccountId')
const type = c.req.query('type')
const name = c.req.query('name')
.get(
'/curatedlists',
describeRoute({
tags: ['Lists', '2025'],
summary: 'One curated list',
description: [
'ONE list object — not a collection — asked for with the same three parameters whether',
'the client wants a discovery PAGEs row set or a PLAYERs own playlist:',
'',
'- A pages rows are a static capture in `static/curated-lists.json`, whose `ItemIds`',
' are the discovery section keys the page is built from (not room ids).',
'- A players playlist lives in D1, in the `list` / `list_item` tables this worker owns.',
' `__SavedForLater_Rooms` is the one the client creates for itself — the Play menus',
' “Saved for Later” row, asked for with the players own id and `type=1` (Rooms), so its',
' `ItemIds` are room ids.',
'',
'D1 is asked FIRST, so a players own list wins over a capture that happens to share its',
'name: the captures are this servers fixtures and a players list is their data.',
'',
'Not auth-gated — the client names the owner rather than proving it, `Accessibility` is a',
'property of the list rather than of the reader, and the answer is only ever ids the',
'client then resolves itself.',
'',
'A name matching NEITHER 404s: answering it with an unrelated capture puts one pages',
'rows under another pages heading, which reads as real content rather than as a missing',
'list. The two exceptions are real answers rather than misses — a reserved `__` playlist',
'nobody owns yet comes back EMPTY, and a request naming no list at all gets the page',
'default for its `type`.',
].join('\n'),
parameters: [CREATOR_ACCOUNT_ID_PARAM, LIST_TYPE_PARAM, LIST_NAME_PARAM],
responses: {
200: json(CuratedListRead, 'The list'),
404: { description: 'No list of that name, and it is not a reserved playlist' },
},
}),
async (c) => {
const creatorAccountId = c.req.query('creatorAccountId')
const type = c.req.query('type')
const name = c.req.query('name')
const list =
(await ownedList(c, creatorAccountId, type, name)) ??
resolveCuratedList(creatorAccountId, type, name)
if (list === undefined) return c.notFound()
const list =
(await ownedList(c, creatorAccountId, type, name)) ??
resolveCuratedList(creatorAccountId, type, name)
if (list === undefined) return c.notFound()
// Serialized by hand rather than through `c.json`: the reference's `ListId`s are
// 64-bit and are carried as strings so their digits survive being parsed — see
// `serializeCuratedList`, which puts them back on the wire as numbers.
return c.body(serializeCuratedList(list), 200, { 'content-type': 'application/json' })
})
// Serialized by hand rather than through `c.json`: the reference's `ListId`s are
// 64-bit and are carried as strings so their digits survive being parsed — see
// `serializeCuratedList`, which puts them back on the wire as numbers.
return c.body(serializeCuratedList(list), 200, { 'content-type': 'application/json' })
}
)
// Save an item into one of the caller's own lists, creating the list if they don't have
// it yet (`PUT /curatedlists/:name/items/:itemId/createlistifneeded`) — what the client
@@ -378,37 +468,69 @@ const app = new Hono<App>()
//
// Answers the list as it now stands rather than an acknowledgement, so the row the client
// re-renders is the one this call just changed.
.put('/curatedlists/:name/items/:itemId/createlistifneeded', async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const list = await addPlayerListItem(
c.env.DB,
{
creatorAccountId: accountId,
name: c.req.param('name'),
// The `ListEntityType`, saying what the item ids in this list ARE. Rooms when the
// body names none: every list the client creates this way is a room list, and the
// type is part of the list's identity, so guessing another would strand the list
// where the client's own read (`?type=1`) can't find it.
type: intField(body, c, 'type', ListEntityType.Rooms),
// PRIVATE by default. A list a player builds for themselves is theirs to see;
// the client sends `accessibility=0` and this only applies on creation anyway.
accessibility: intField(body, c, 'accessibility', Accessibility.Private),
.put(
'/curatedlists/:name/items/:itemId/createlistifneeded',
describeRoute({
tags: ['Lists', '2025'],
summary: 'Save an item into the callers list',
description: [
'Saves an item into one of the callers own lists, creating the list when they have none',
'by that name — what the client calls when someone saves a room for later. The path names',
'the list and the item; the form body carries `accessibility` and `type`, both of which',
'apply only on creation.',
'',
'AUTH-GATED, and the owner is the TOKENs account: unlike the read, this call names no',
'`creatorAccountId`, so the only account it could mean is the callers — and taking an',
'owner from the client would let anyone write into anyones list.',
'',
'Answers the list as it now stands rather than an acknowledgement, so the row the client',
're-renders is the one this call just changed. Saving the same item twice leaves it in',
'the list once.',
'',
'The response drops `Accessibility`, which the read keeps. That is a real difference in',
'what the client is sent, not an oversight — every other key, and their order, is the',
'reads.',
].join('\n'),
security: AUTHED,
parameters: [SAVE_LIST_NAME_PARAM, ITEM_ID_PARAM],
requestBody: form(SaveItemBody, 'Applied only when the list is created'),
responses: {
200: json(CuratedListSaved, 'The list as it now stands, without `Accessibility`'),
401: UNAUTHORIZED_RESPONSE,
},
c.req.param('itemId')
)
}),
async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
// The SAVE's projection of a list drops `Accessibility`; the read's keeps it. That is a
// real difference in what the client is sent, not an oversight — don't unify them.
// Every other key, and their order, is the read's.
const { Accessibility: _accessibility, ...saved } = list
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const list = await addPlayerListItem(
c.env.DB,
{
creatorAccountId: accountId,
name: c.req.param('name'),
// The `ListEntityType`, saying what the item ids in this list ARE. Rooms when the
// body names none: every list the client creates this way is a room list, and the
// type is part of the list's identity, so guessing another would strand the list
// where the client's own read (`?type=1`) can't find it.
type: intField(body, c, 'type', ListEntityType.Rooms),
// PRIVATE by default. A list a player builds for themselves is theirs to see;
// the client sends `accessibility=0` and this only applies on creation anyway.
accessibility: intField(body, c, 'accessibility', Accessibility.Private),
},
c.req.param('itemId')
)
// Serialized by hand for the same reason the read is: the 64-bit `ListId` has to reach
// the client unquoted with every digit intact.
return c.body(serializeCuratedList(saved), 200, { 'content-type': 'application/json' })
})
// The SAVE's projection of a list drops `Accessibility`; the read's keeps it. That is a
// real difference in what the client is sent, not an oversight — don't unify them.
// Every other key, and their order, is the read's.
const { Accessibility: _accessibility, ...saved } = list
// Serialized by hand for the same reason the read is: the 64-bit `ListId` has to reach
// the client unquoted with every digit intact.
return c.body(serializeCuratedList(saved), 200, { 'content-type': 'application/json' })
}
)
// One discovery ROW's contents (`GET /algorithmiclists/:list?type=1`). `:list` is the row
// key the curated page above lists in its `ItemIds` (e.g.
@@ -422,54 +544,151 @@ const app = new Hono<App>()
// instead of one it hides. `Type` is echoed back from the query: it
// tells the client what the `Id`s ARE (rooms, players, …), so answering with a type the
// caller didn't ask for would have it resolve the ids against the wrong service.
.get('/algorithmiclists/:list', async (c) => {
// Echoed, but only when it fits the byte the client reads it back into — anything
// outside 0255 can't round-trip, so a nonsense `?type=` gets the default instead of a
// number that would break the response on the way in.
const type = Number.parseInt(c.req.query('type') ?? '', 10)
const echoed = type >= 0 && type <= MAX_LIST_ENTITY_TYPE ? type : DEFAULT_ALGORITHMIC_LIST_TYPE
.get(
'/algorithmiclists/:list',
describeRoute({
tags: ['Lists', '2025'],
summary: 'One discovery rows contents',
description: [
'The entities that fill one discovery row. `{list}` is the row key a curated page lists',
'in its `ItemIds` (e.g. `Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore`), and',
'only the IDS travel — the client resolves each room or item itself.',
'',
'`HotList`, `recentlyupdated` and `new` are ranked live off the same room tables the',
'`rooms` workers browse feeds read, so a row and its feed cant disagree. The',
'`*_algoendpoint` category rows serve the public rooms carrying one tag, busiest first.',
'`recentlyvisited` is per-caller and is the one row that reads the token; without one it',
'answers EMPTY rather than 401ing, since canned rooms would claim the caller visited',
'rooms they never did — and an empty carousel is what a brand-new account legitimately',
'has. A couple of store rows are hand-picked id lists.',
'',
'Every other row — an unknown key included — answers an EMPTY 200 rather than a 404,',
'which the client renders as a row that failed to load instead of one it hides.',
].join('\n'),
parameters: [ALGORITHMIC_LIST_PARAM, ALGORITHMIC_TYPE_PARAM],
responses: { 200: json(AlgorithmicList, 'The rows entities, possibly none') },
}),
async (c) => {
// Echoed, but only when it fits the byte the client reads it back into — anything
// outside 0255 can't round-trip, so a nonsense `?type=` gets the default instead of a
// number that would break the response on the way in.
const type = Number.parseInt(c.req.query('type') ?? '', 10)
const echoed =
type >= 0 && type <= MAX_LIST_ENTITY_TYPE ? type : DEFAULT_ALGORITHMIC_LIST_TYPE
const key = c.req.param('list').toLowerCase()
const key = c.req.param('list').toLowerCase()
// A per-caller row needs to know who is asking, so it is the one kind of row that
// reads the token. No token — or one that doesn't resolve — answers an EMPTY row
// rather than 401ing or falling through to the canned entities: this is a row about
// what the caller has done, and canned rooms would claim they visited rooms they
// never did. An empty carousel is also what a brand-new account legitimately has.
const personal = PERSONAL_ROW_FEEDS[key]
if (personal !== undefined) {
const accountId = await authedId(c)
const rooms = accountId === null ? [] : await personal(c.env.DB, accountId)
return c.json({ Type: echoed, Entities: toEntities(rooms) })
// A per-caller row needs to know who is asking, so it is the one kind of row that
// reads the token. No token — or one that doesn't resolve — answers an EMPTY row
// rather than 401ing or falling through to the canned entities: this is a row about
// what the caller has done, and canned rooms would claim they visited rooms they
// never did. An empty carousel is also what a brand-new account legitimately has.
const personal = PERSONAL_ROW_FEEDS[key]
if (personal !== undefined) {
const accountId = await authedId(c)
const rooms = accountId === null ? [] : await personal(c.env.DB, accountId)
return c.json({ Type: echoed, Entities: toEntities(rooms) })
}
// A row with a live feed behind it serves that; everything else gets the canned
// entities. Only the ids travel — the client resolves each room itself — so the
// ranking is read for its order and the room blobs are thrown away.
const feed = ROW_FEEDS[key]
if (feed !== undefined) {
return c.json({ Type: echoed, Entities: toEntities(await feed(c.env.DB)) })
}
// Then the hand-picked rows, which are already entities: the ids are the answer.
const canned = STATIC_ROW_ENTITIES[key]
if (canned !== undefined) {
return c.json({ Type: echoed, Entities: canned })
}
return c.json({ Type: echoed, Entities: ALGORITHMIC_LIST_ENTITIES })
}
// A row with a live feed behind it serves that; everything else gets the canned
// entities. Only the ids travel — the client resolves each room itself — so the
// ranking is read for its order and the room blobs are thrown away.
const feed = ROW_FEEDS[key]
if (feed !== undefined) {
return c.json({ Type: echoed, Entities: toEntities(await feed(c.env.DB)) })
}
// Then the hand-picked rows, which are already entities: the ids are the answer.
const canned = STATIC_ROW_ENTITIES[key]
if (canned !== undefined) {
return c.json({ Type: echoed, Entities: canned })
}
return c.json({ Type: echoed, Entities: ALGORITHMIC_LIST_ENTITIES })
})
)
// Contextual features — the client posts the context it's in and reads back whether the
// call was accepted. Auth-gated, and the answer is a bare `{ success, error_id, error }`
// with no payload: the reference server acknowledges the post and carries nothing back,
// so there is nothing here to serve statically beyond the acknowledgement itself. The
// body is read for the log only.
.post('/contextualfeatures', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.post(
'/contextualfeatures',
describeRoute({
tags: ['Lists', '2025'],
summary: 'Acknowledge a contextual-features post',
description: [
'The client posts the context it is in and reads back whether the call was accepted.',
'Auth-gated, and the answer is a bare `{ success, error_id, error }` with no payload:',
'the reference server acknowledges the post and carries nothing back, so there is',
'nothing here to serve beyond the acknowledgement itself. The body is read for the log',
'only.',
].join('\n'),
security: AUTHED,
responses: {
200: json(ContextualFeaturesAck, 'Accepted'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ success: true, error_id: null, error: null })
})
return c.json({ success: true, error_id: null, error: null })
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare lists',
version: '1.0.0',
description: [
'Curated and algorithmic lists for recflare, a private-server reimplementation of the',
'Rec Room backend. A discovery page is built from these: the `discovery` worker says',
'which carousels a page has, and this worker says what is in them.',
'',
'Two kinds of list. A CURATED list is named — either a static capture of a pages row',
'set, or a players own playlist in D1 (`__SavedForLater_Rooms`, the Play menus',
'“Saved for Later”). An ALGORITHMIC list is a ranking asked for by row slug: the hot,',
'recently-updated and new feeds, the room categories, and the callers own recently',
'visited rooms.',
'',
'Only IDS travel. Every list answers ids the client resolves against the `rooms` and',
'`commerce` workers itself, which is why a list carries a `Type` saying what its ids',
'ARE — answering with a type the caller didnt ask for would have it look the ids up',
'against the wrong service.',
'',
'A row with nothing behind it answers an empty 200 rather than a 404: the client',
'renders a failed row for an error and hides an empty one, and an empty carousel is',
'the honest answer for a ranking this server has nothing for.',
'',
'Reads are unauthenticated — the client names the owner rather than proving it, and a',
'list is only ever ids. Writing needs a token, since the list written into is the',
'callers own.',
].join('\n'),
},
servers: [{ url: 'https://lists.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+287
View File
@@ -0,0 +1,287 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the lists worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to generate
* the spec and are never wired into `hono-openapi`'s `validator()`. Same rationale as
* the other workers: a reverse-engineered protocol, lenient handlers, no runtime
* validation.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist into
* `components.schemas`, leaving a dangling reference. Leaving meta off makes every schema
* inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/**
* A form-encoded request body. The client posts `application/x-www-form-urlencoded`; Hono's
* `parseBody()` also reads multipart, so both are documented on the one body.
*/
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const f = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: f },
'multipart/form-data': { schema: f },
},
}
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
// ---- Parameters ------------------------------------------------------------
/**
* The repeated `?id=` query the bulk lookup selects on — repetition, not a delimiter, so
* `explode: true` form style rather than one comma-joined value.
*
* Documented for the shape of the request only: nothing curates lists here yet, so the
* answer is the same canned list whatever is asked for (see `CuratedListsBulk`).
*/
export const LIST_IDS_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'id',
in: 'query',
required: false,
description: [
'A list id to look up, repeated once per list wanted. Ignored today — the canned list',
'is served whatever is asked for, an unknown id included, because a 404 renders as a',
'row that failed to load rather than one the client hides.',
].join(' '),
style: 'form',
explode: true,
schema: { type: 'array', items: { type: 'string' } },
example: ['17859340'],
}
/**
* `?creatorAccountId=` on the read. NOT auth: the client asks for its own lists by naming
* its account id, and `Accessibility` is a property of the list rather than of the reader.
*/
export const CREATOR_ACCOUNT_ID_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'creatorAccountId',
in: 'query',
required: false,
description: [
'Who owns the list. Matched exactly against a stored list, and used as the most',
'specific key against the static captures — a creator nothing owns falls back to',
'matching on type and name. Echoed back on an unowned reserved list, so the client',
'still sees the list it asked for.',
].join(' '),
schema: { type: 'integer', example: 42 },
}
/**
* `?type=` on the read — the `ListEntityType`, which is what the `ItemIds` ARE. See the
* note on `resolveCuratedList`: it is NOT the page-source enum, even though the captures
* are pages.
*/
export const LIST_TYPE_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'type',
in: 'query',
required: false,
description: [
'The `ListEntityType` — what the lists `ItemIds` are: 0 Accounts · 1 Rooms ·',
'2 Inventions · 3 CustomAvatarItems · 4 PurchasableItems · 5 Generic · 6 ChipAndPort ·',
'7 DiscoverySection · 8 DiscoverySectionSubType. Part of a lists identity, not a',
'filter: `__SavedForLater_Rooms` is asked for with `type=1` (its items are room ids)',
'while every static capture is `type=7` (its items are discovery section keys).',
].join(' '),
schema: { type: 'integer', example: 1 },
}
/** `?name=` on the read — the list itself. */
export const LIST_NAME_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'name',
in: 'query',
required: false,
description: [
'The lists name, matched case-insensitively (the casing that arrives is the',
'clients). Naming NO list asks for the page default for `type`; naming one that',
'matches nothing is a 404, unless it is one of the clients own reserved `__`',
'playlists, which answers empty.',
].join(' '),
schema: { type: 'string', example: '__SavedForLater_Rooms' },
}
/** `{name}` on the save — the list written into, created when the caller has none. */
export const SAVE_LIST_NAME_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'name',
in: 'path',
required: true,
description: [
'The callers list to save into, created if they have none by that name.',
'`__SavedForLater_Rooms` is the one the client creates for itself — the Play menus',
'“Saved for Later” row.',
].join(' '),
schema: { type: 'string', example: '__SavedForLater_Rooms' },
}
/** `{itemId}` on the save — what goes into the list, as a string. */
export const ITEM_ID_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'itemId',
in: 'path',
required: true,
description: [
'The item to save, as a string — a room id for the room lists the client builds this',
'way. Saving the same item twice leaves it in the list once.',
].join(' '),
schema: { type: 'string', example: '953' },
}
/**
* `{list}` on a discovery row — the row SLUG, which is what a curated page's `ItemIds` and
* a discovery section's `sourceMetadata` name.
*/
export const ALGORITHMIC_LIST_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'list',
in: 'path',
required: true,
description: [
'The row key, matched case-insensitively. `HotList`, `recentlyupdated` and `new` are',
'ranked for real; `recentlyvisited` is per-caller; the seven `*_algoendpoint` category',
'rows serve the public rooms carrying one tag; `summerpartycarousel` and `newitems` are',
'hand-picked store ids. Every other key — an unknown one included — answers an empty',
'row with a 200.',
].join(' '),
// Deliberately not an `enum`: an unknown slug is a legal request that answers an empty
// row, so freezing today's keys here would document a rejection that never happens.
schema: { type: 'string', example: 'HotList' },
}
/** `?type=` on a discovery row — echoed back, saying what the row's `Id`s are. */
export const ALGORITHMIC_TYPE_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'type',
in: 'query',
required: false,
description: [
'The `ListEntityType` the caller wants the rows ids read as, ECHOED back on the',
'response — it tells the client which service to resolve the ids against. A BYTE on',
'the client, so a value outside 0255 (or none at all) is answered with 1, Rooms,',
'which is what the client always asks for.',
].join(' '),
schema: { type: 'integer', minimum: 0, maximum: 255, example: 1 },
}
// ---- Response schemas ------------------------------------------------------
/**
* One curated list as the client parses it, out of D1 or out of a static capture.
*
* `ListId` is a NUMBER on the wire — a quoted id fails the client's parser — and the
* reference's ids are 64-bit (`624765592684307326`), past what a JS number holds exactly.
* They are carried as strings internally and unquoted on the way out, so this is `number`
* rather than a bounded integer.
*/
const CuratedListFields = {
ListId: z
.number()
.describe('64-bit; 0 on an unowned reserved list, since nothing was stored to have an id'),
CreatorAccountId: z.int().describe('The owner; echoed from the query on a reserved list'),
Name: z.string(),
Description: z.string().nullable(),
ImageName: z
.string()
.describe(
'Must be a STRING — the client reads it straight into a string field. `DefaultRoomImage.jpg` where nothing set one; empty or null renders a blank tile.'
),
Type: z.int().describe('The `ListEntityType` — what the `ItemIds` are'),
ItemIds: z
.string()
.array()
.describe(
'Strings even where they stand for numeric ids, in the order they were added — which is the order the row displays them.'
),
CreatedAt: z.string().describe('ISO-8601 UTC'),
}
/**
* The READ's projection, which keeps `Accessibility`. The save's drops it — a real
* difference in what the client is sent, not an oversight; don't unify them.
*/
export const CuratedListRead = z.object({
...CuratedListFields,
Accessibility: z
.int()
.optional()
.describe(
'The `Accessibility` enum — 0 Private · 1 Public (its Unlisted/Dev members exist but nothing sets one on a list). Carried by every list the read serves, stored or captured; absent from the canned bulk list and from the saves response.'
),
})
/** The SAVE's projection: every key of the read, in the read's order, minus `Accessibility`. */
export const CuratedListSaved = z.object(CuratedListFields)
/** `GET /curatedlists/bulk` — a list per id asked for; today one canned list, always. */
export const CuratedListsBulk = CuratedListRead.array()
/**
* One entity of a row. `Id` is a STRING even though most of what a row names (rooms, store
* items) is numbered, and `Context` is where the reference attributes the ranking or
* experiment that produced the entity — nothing here produces one, so it is null on every
* entity rather than a made-up context the client would carry into telemetry.
*/
export const ListEntityDto = z.object({
Id: z.string().describe('The room/item id the client resolves itself'),
Context: z.string().nullable().describe('Ranking attribution; always null here'),
})
/**
* `GET /algorithmiclists/{list}` — one discovery row's contents. Only ids travel: the
* client resolves each room or item against the `rooms`/`commerce` workers itself.
*/
export const AlgorithmicList = z.object({
Type: z.int().describe('The `ListEntityType`, echoed from `?type=` — see the parameter'),
Entities: ListEntityDto.array().describe('Empty for a row with nothing behind it'),
})
/**
* `POST /contextualfeatures` — the bare acknowledgement, with no payload. The reference
* server carries nothing back, so there is nothing here to serve beyond the ack itself.
*/
export const ContextualFeaturesAck = z.object({
success: z.literal(true),
error_id: z.null(),
error: z.null(),
})
// ---- Request schemas -------------------------------------------------------
/**
* The form-encoded save body the client sends (`accessibility=0&type=1`). Both fields are
* also read off the query string: the same parameters ride the query everywhere else on
* this worker, and a body that failed to parse would otherwise silently create a list with
* the wrong type.
*/
export const SaveItemBody = z.object({
type: z
.string()
.optional()
.describe(
'The `ListEntityType` the list is created with (integer, as text). Rooms (1) when absent — every list the client creates this way is a room list, and the type is part of the lists identity, so another value would strand it where the clients own `?type=1` read cant find it.'
),
accessibility: z
.string()
.optional()
.describe(
'The `Accessibility` enum — 0 Private · 1 Public (integer, as text). PRIVATE when absent: a list a player builds for themselves is theirs to see, and the client sends `accessibility=0`. Applied only on creation — a later save leaves an existing lists accessibility alone.'
),
})
@@ -936,3 +936,21 @@ it('serves the rows when there are more rooms than D1 allows bound parameters',
await env.DB.prepare('DELETE FROM room WHERE room_id >= ?1').bind(FIRST).run()
await env.DB.prepare('DELETE FROM room_tag WHERE room_id >= ?1').bind(FIRST).run()
})
it('generates a spec with no dangling $refs', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as { paths: Record<string, unknown> }
expect(Object.keys(spec.paths)).toEqual(
expect.arrayContaining([
'/curatedlists',
'/curatedlists/bulk',
'/curatedlists/{name}/items/{itemId}/createlistifneeded',
'/algorithmiclists/{list}',
'/contextualfeatures',
])
)
// The spec route keeps itself out of its own output.
expect(Object.keys(spec.paths)).not.toContain('/openapi.json')
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
})