mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[openapi] define missing routes
This commit is contained in:
@@ -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
@@ -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 PAGE’s row set or a PLAYER’s own playlist:',
|
||||
'',
|
||||
'- A page’s 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 player’s 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 menu’s',
|
||||
' “Saved for Later” row, asked for with the player’s own id and `type=1` (Rooms), so its',
|
||||
' `ItemIds` are room ids.',
|
||||
'',
|
||||
'D1 is asked FIRST, so a player’s own list wins over a capture that happens to share its',
|
||||
'name: the captures are this server’s fixtures and a player’s 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 page’s',
|
||||
'rows under another page’s 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 caller’s list',
|
||||
description: [
|
||||
'Saves an item into one of the caller’s 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 TOKEN’s account: unlike the read, this call names no',
|
||||
'`creatorAccountId`, so the only account it could mean is the caller’s — and taking an',
|
||||
'owner from the client would let anyone write into anyone’s 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',
|
||||
'read’s.',
|
||||
].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 0–255 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 row’s 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` worker’s browse feeds read, so a row and its feed can’t 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 row’s entities, possibly none') },
|
||||
}),
|
||||
async (c) => {
|
||||
// Echoed, but only when it fits the byte the client reads it back into — anything
|
||||
// outside 0–255 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 page’s row',
|
||||
'set, or a player’s own playlist in D1 (`__SavedForLater_Rooms`, the Play menu’s',
|
||||
'“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 caller’s 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 didn’t 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',
|
||||
'caller’s 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 worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -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 list’s `ItemIds` are: 0 Accounts · 1 Rooms ·',
|
||||
'2 Inventions · 3 CustomAvatarItems · 4 PurchasableItems · 5 Generic · 6 ChipAndPort ·',
|
||||
'7 DiscoverySection · 8 DiscoverySectionSubType. Part of a list’s 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 list’s name, matched case-insensitively (the casing that arrives is the',
|
||||
'client’s). Naming NO list asks for the page default for `type`; naming one that',
|
||||
'matches nothing is a 404, unless it is one of the client’s 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 caller’s list to save into, created if they have none by that name.',
|
||||
'`__SavedForLater_Rooms` is the one the client creates for itself — the Play menu’s',
|
||||
'“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 row’s 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 0–255 (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 save’s 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 list’s identity, so another value would strand it where the client’s own `?type=1` read can’t 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 list’s 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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user