[leaderboard] update openapi

This commit is contained in:
Devin Zuczek
2026-08-21 15:45:32 -04:00
parent dcaff859d0
commit 6fd8d6074c
6 changed files with 259 additions and 13 deletions
+6 -1
View File
@@ -16,8 +16,13 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"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",
+113 -6
View File
@@ -1,13 +1,16 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { GetNearbyScoresBody, GetRanksBody, json, jsonBody, LeaderboardRows } from './openapi'
import type { App } from './context'
/**
* Leaderboard Worker. Nothing scores anything here yet — the one route answers the shape
* the client parses, with no rows in it.
* Leaderboard Worker. Nothing scores anything here yet — the routes answer the shape the
* client parses, with no rows in them.
*/
const app = new Hono<App>()
.use(
@@ -23,9 +26,24 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', async (c) => {
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description:
'Liveness probe for the leaderboard worker. Answers `text/plain`, not JSON, unlike the other workers health checks. No auth.',
responses: {
200: {
description: 'Service is up',
content: { 'text/plain': { schema: { type: 'string' } } },
},
},
}),
async (c) => {
return c.text('hello, world!')
})
}
)
// The scores around a player — what the client shows when it opens a leaderboard on
// someone rather than at the top. Answers `{ Rows: [...] }`; an EMPTY `Rows` is a
@@ -37,12 +55,101 @@ const app = new Hono<App>()
// body's shape hasn't been recovered from the client, and this route is how it gets
// watched. Read it as text — the shape is unknown, so parsing it would only invent one —
// and never fail on it, since an unreadable body must not cost the client its board.
.post('/leaderboard/GetNearbyScores', async (c) => {
.post(
'/leaderboard/GetNearbyScores',
describeRoute({
tags: ['Leaderboard'],
summary: 'The scores around a player',
description: [
'What the client shows when it opens a leaderboard ON someone rather than at the top.',
'',
'Nothing is scored or stored on this server yet, so `Rows` is always empty — a complete',
'answer meaning "this leaderboard has no scores", which the client renders as a blank',
'board rather than failing. The key is always present; a bare `{}` trips its parser.',
'',
'The request body is IGNORED, and logged rather than parsed: its shape has not been',
'recovered from the client, so this route is how it gets watched. An unreadable body is',
'not an error either — it must not cost the client its board.',
].join(' '),
requestBody: jsonBody(GetNearbyScoresBody, 'Ignored and logged; shape not yet recovered'),
responses: { 200: json(LeaderboardRows, 'The board, always with no rows') },
}),
async (c) => {
const body = await c.req.text().catch(() => '<unreadable>')
logger.info('GetNearbyScores', { body })
const rows: unknown[] = []
return c.json({ Rows: rows })
}
)
// A page of the board itself — what the client shows when it opens a leaderboard at the
// top rather than on a player. The body names the slice (`RankStart`/`RankEnd`, both
// inclusive), the board (`RoomId` + `StatChannel`), the viewer (`PlayerId`) and the
// ordering (`FilterType`, `SortAscending`).
//
// Same answer and same rules as GetNearbyScores: `{ Rows: [...] }`, where an EMPTY
// `Rows` is a complete answer meaning "this leaderboard has no scores" and the key must
// be present. Nothing is ranked or stored yet, so the body is ignored — only logged, and
// read as text so an unreadable body can never cost the client its board.
.post(
'/leaderboard/GetRanks',
describeRoute({
tags: ['Leaderboard'],
summary: 'A page of the board',
description: [
'What the client shows when it opens a leaderboard at the TOP rather than on a player.',
'The body names the slice (`RankStart`/`RankEnd`, both inclusive), the board (`RoomId`',
'plus `StatChannel`), the viewer (`PlayerId`) and the ordering (`FilterType`,',
'`SortAscending`).',
'',
'Answers exactly what `GetNearbyScores` answers, under the same rules: `Rows` is always',
'empty because nothing is scored or stored here yet, and the key is always present.',
'',
'The body is IGNORED — it is logged, not parsed — so the fields are documented as the',
'record of what the client asks for rather than as anything the handler reads.',
].join(' '),
requestBody: jsonBody(GetRanksBody, 'The slice and board the client is asking for'),
responses: { 200: json(LeaderboardRows, 'The board, always with no rows') },
}),
async (c) => {
const body = await c.req.text().catch(() => '<unreadable>')
logger.info('GetRanks', { body })
const rows: unknown[] = []
return c.json({ Rows: rows })
}
)
// 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 leaderboard',
version: '1.0.0',
description: [
'Leaderboards for recflare, a private-server reimplementation of the Rec Room',
'backend — the boards a room keeps for the stats it tracks.',
'',
'NOTHING IS SCORED HERE YET. Both reads answer `{ "Rows": [] }`, which is a complete',
'answer rather than an error: an empty list means "this leaderboard has no scores"',
'and the client renders a blank board. The `Rows` key is always present — a bare',
'`{}` trips the clients parser.',
'',
'Neither route reads its request body. Both log it verbatim instead, which is how',
'the shapes below get recovered from a live client; `GetNearbyScores` body is',
'still unknown for exactly that reason. No route needs a token today.',
].join('\n'),
},
servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }],
},
})
)
)
export default app
+89
View File
@@ -0,0 +1,89 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the leaderboard 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
* auth/accounts/econ/match workers: a reverse-engineered protocol, lenient handlers, no
* runtime validation. Here it matters more than usual — the handlers do not parse their
* bodies at all yet, so a body that contradicts the schema below is still answered.
*
* 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) } } }
}
/**
* Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s
* `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and
* `additionalProperties: false` are dropped — the handlers read nothing out of these
* bodies at all, so a closed object would misreport them as stricter than they are.
*/
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** An `application/json` request body — what the client posts to both reads. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
// ---- Response schemas ------------------------------------------------------
/**
* Both leaderboard reads answer this and nothing else: `{ Rows: [...] }`.
*
* `Rows` is EMPTY on this server — nothing scores anything yet — and an empty list is a
* complete answer meaning "this leaderboard has no scores", which the client renders as a
* blank board rather than failing. The key must be present; a bare `{}` trips its parser.
*
* The row shape is therefore undocumented: no live response has ever carried one, so
* describing a row here would be inventing it. It is typed as an open object rather than
* `unknown` so a viewer shows an object in the array.
*/
export const LeaderboardRows = z.object({
Rows: z
.array(z.looseObject({}))
.describe('The boards rows. Always empty — nothing is scored or stored yet.'),
})
// ---- Request schemas -------------------------------------------------------
/**
* The body the client posts to `GetRanks`, e.g.
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
*
* Recovered from a live client, not from a spec, and IGNORED by the handler today — the
* board is empty whatever it says. It is documented because it is the record of what the
* client asks for, which is what an implementation will have to answer.
*/
export const GetRanksBody = z.object({
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
RankEnd: z.int().describe('Last rank of the slice, inclusive — 09 is the first ten'),
PlayerId: z.int().describe('The player reading the board'),
StatChannel: z.int().describe('Which of the rooms tracked stats to rank on'),
RoomId: z.int().describe('The room whose board is being read'),
FilterType: z.int().describe('Client-side filter selector; its members arent known yet'),
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
})
/**
* The body posted to `GetNearbyScores`. Its shape has NOT been recovered from the client —
* the handler logs the raw text precisely so it can be — so this documents an open object
* rather than guessing fields. Expect it to name a player and a board the way
* {@link GetRanksBody} does.
*/
export const GetNearbyScoresBody = z
.looseObject({})
.describe('Unknown shape — logged by the handler so it can be recovered from a live client.')
@@ -15,3 +15,32 @@ it('answers GetNearbyScores with an empty row list', async () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Rows: [] })
})
it('answers GetRanks with an empty row list', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', {
method: 'POST',
body: JSON.stringify({
RankStart: 0,
RankEnd: 9,
PlayerId: 2,
StatChannel: 1,
RoomId: 6,
FilterType: 0,
SortAscending: false,
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Rows: [] })
})
it('serves an openapi spec with no dangling refs', async () => {
const res = await SELF.fetch('https://example.com/openapi.json')
expect(res.status).toBe(200)
const spec = (await res.json()) as Record<string, unknown>
expect(Object.keys(spec.paths as object).sort()).toEqual([
'/',
'/leaderboard/GetNearbyScores',
'/leaderboard/GetRanks',
])
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
})
+1
View File
@@ -31,6 +31,7 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }>
{ slug: 'playersettings', title: 'playersettings — per-player settings' },
{ slug: 'roomcomments', title: 'roomcomments — notes pinned in a room' },
{ slug: 'discovery', title: 'discovery — discovery page layouts' },
{ slug: 'leaderboard', title: 'leaderboard — room score boards' },
{ slug: 'ai', title: 'ai — game AI access' },
{ slug: 'api', title: 'api — everything else' },
]
+15
View File
@@ -672,12 +672,27 @@ importers:
'@repo/hono-helpers':
specifier: workspace:*
version: link:../../packages/hono-helpers
'@standard-community/standard-json':
specifier: 0.3.5
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
'@standard-community/standard-openapi':
specifier: 0.2.9
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
hono:
specifier: 4.12.27
version: 4.12.27
hono-openapi:
specifier: 1.3.1
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
openapi-types:
specifier: 12.1.3
version: 12.1.3
workers-tagged-logger:
specifier: 1.0.1
version: 1.0.1
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies:
'@cloudflare/vitest-pool-workers':
specifier: 0.16.20