Files
recflare/apps/lists/src/openapi.ts
T
2026-08-25 17:20:17 -04:00

289 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
.nullable()
.describe(
'A STRING on any list the client draws a tile for — it reads this straight into a string field, and empty or null renders that tile blank. `DefaultRoomImage.jpg` where nothing set one. Null only on a list with no tile to draw, like the `RoomGenreTags` capture, whose items are genre names rather than rooms.'
),
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.'
),
})