Files
recflare/apps/discovery/src/openapi.ts
T
2026-08-21 15:16:00 -04:00

130 lines
5.7 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 discovery 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) } } }
}
/** The `{type}` path parameter, which is the filename in `static/`. */
export const PAGE_SOURCE_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'type',
in: 'path',
required: true,
description: [
'The page source — `WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`,',
'`PlayCategories`, `StoreCategories`, `StoreFeatured`, `StoreClothing`,',
'`StoreConsumables` at the time of writing. It names a file in `static/`',
'(`<type>.json`) and is matched exactly, case included, so the set is whatever is',
'published rather than anything this worker enumerates.',
'',
'`sections` is a file in `static/` too but is not one of these: it is the id-keyed',
'catalogue `/sections/bulk` filters, not a page anything draws.',
].join(' '),
// Deliberately not an `enum`: the accepted values are the published files, and a spec
// that froze today's list would be wrong the moment one is added.
schema: { type: 'string', example: 'WatchHome' },
}
/**
* The repeated `?id=` query the bulk lookup selects on. Repetition, not a delimiter: the
* client sends `?id=A&id=B&id=C`, so this is `explode: true` form style rather than a
* single comma-joined value.
*/
export const SECTION_IDS_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'id',
in: 'query',
required: false,
description: [
'A section id to look up, repeated once per section wanted. Ids that match nothing are',
'skipped rather than erroring, and repeating one still yields it once — the answer is',
'the catalogue filtered, so it can only ever be a subset of it. Omitting the parameter',
'entirely answers `[]`.',
].join(' '),
style: 'form',
explode: true,
schema: { type: 'array', items: { type: 'string' } },
example: [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
],
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('discovery'),
status: z.literal('ok'),
})
/**
* How one carousel on a discovery page is filled and drawn. `source`/`sourceMetadata`
* name a feed (`Hot`, `Recent`, `PlaylistById` + an id, `CarouselEndpoint` + a slug, …)
* that the client resolves against the `rooms`/`api` workers itself — this worker only
* says WHICH carousels a page has and in what order, never their contents.
*/
export const DiscoverySection = z.object({
id: z.string().describe('Unique id of this section on this page, e.g. `Rooms_RRO_WatchHome`'),
sectionType: z
.int()
.describe(
'What the section lists: 0 RoomsSection · 1 AccountsSection · 2 InventionsSection · ' +
'3 ClubsSection · 4 StoreItemsSection · 5 EventsSection · 6 RoomBanner · 7 Top5Section · ' +
'8 CustomAvatarItemsSection · 9 AdsSection · 10 SkusSection · 11 RoomCategorySection · ' +
'12 RoomCategoryListSection · 13 DiscoverySection'
),
sectionSubType: z.string().describe('The section’s kind, shared across pages, e.g. `Rooms_RRO`'),
source: z.string().describe('The feed that fills the section'),
sourceMetadata: z
.string()
.nullable()
.describe('Argument to `source` — a carousel slug, a playlist id, … `null` when it takes none'),
displayMetadata: z
.string()
.nullable()
.describe(
'How the section is drawn (`DisplayTitle`, `numRows`, `backgroundColor`, …), as an ' +
'embedded JSON *string* the client parses itself — not an object. Its booleans and ' +
'numbers are quoted in most sections and bare in some; both forms are live in the ' +
'captures, so the client evidently takes either. `null` where a section is drawn ' +
'however its type says (several store and play-highlight sections).'
),
})
/**
* A list of sections — `GET /sections/pagesource/{type}` in the order a page draws them,
* or `GET /sections/bulk` in the catalogue's order.
*
* The DTO accepts anything (its validator is a no-op), but the STORE page builder is much
* stricter and drops a section it doesn't like SILENTLY — no error reaches the client, the
* carousel simply isn't there. A section it keeps must have:
*
* - a non-empty `displayMetadata` that parses, and whose gating (platform, junior account,
* account age) says it's supported;
* - `sectionType` 4 (StoreItemsSection → the product carousel) or 13 (DiscoverySection).
* Anything else logs "Unhandled SectionType {0}, skipping";
* - for 13 only, a `source` of exactly `CuratedList` or `PageSource`, with `sourceMetadata`
* carrying the argument — a curated-list id, or another page source to recurse into.
*
* So a store section that renders nothing is worth checking against these before assuming
* the feed behind it is empty.
*/
export const DiscoverySections = DiscoverySection.array()