mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[leaderboard] update openapi
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 client’s 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
|
||||
|
||||
@@ -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 board’s 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 — 0–9 is the first ten'),
|
||||
PlayerId: z.int().describe('The player reading the board'),
|
||||
StatChannel: z.int().describe('Which of the room’s 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 aren’t 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()
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
Generated
+15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user