updating api docs

This commit is contained in:
Devin Zuczek
2026-07-22 11:43:30 -04:00
parent 68b98665b2
commit 23b78104e8
28 changed files with 3358 additions and 780 deletions
+6 -1
View File
@@ -17,8 +17,13 @@
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "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",
+105
View File
@@ -0,0 +1,105 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the playersettings 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.
*
* 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 request body the handler accepts in either encoding. The single write route parses
* form-urlencoded/multipart (`key`/`value`, which is what the client posts) and JSON
* (one object or an array of them), so both are documented on the one body.
*/
export function formOrJson(
formSchema: z.ZodType,
jsonSchema: z.ZodType,
description: string
): OpenAPIV3_1.RequestBodyObject {
const f = toOpenApiSchema(formSchema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: f },
'multipart/form-data': { schema: f },
'application/json': { schema: toOpenApiSchema(jsonSchema) },
},
}
}
/** 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: [] }]
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the root health check. */
export const HealthResponse = z.object({
service: z.literal('playersettings'),
status: z.literal('ok'),
})
/**
* One stored setting as the client reads it (`GET /playersettings`). `Value` is always a
* string — the client stores numbers/bools stringified.
*/
export const PlayerSettingEntry = z.object({
PlayerId: z.int().describe('The authenticated player the setting belongs to'),
Key: z.string(),
Value: z.string(),
})
// ---- Request schemas -------------------------------------------------------
/**
* The form-encoded write the client actually sends: a single `key`/`value` pair (e.g.
* `key=PlayerSessionCount&value=1`). An empty `key` is dropped.
*/
export const SettingFormWrite = z.object({
key: z.string().describe('The setting name; an empty key is ignored'),
value: z.string().describe('The setting value, as a string'),
})
/**
* The JSON form of the same write. Accepted as one object or an array of them, and both
* `key`/`value` and `Key`/`Value` casings are read; numbers and booleans are stringified.
*/
export const SettingJsonWrite = z.union([
z.object({
key: z.string().optional(),
Key: z.string().optional(),
value: z.union([z.string(), z.number(), z.boolean()]).optional(),
Value: z.union([z.string(), z.number(), z.boolean()]).optional(),
}),
z.array(
z.object({
key: z.string().optional(),
Key: z.string().optional(),
value: z.union([z.string(), z.number(), z.boolean()]).optional(),
Value: z.union([z.string(), z.number(), z.boolean()]).optional(),
})
),
])
+130 -25
View File
@@ -1,14 +1,34 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { DEFAULT_SETTINGS } from './default-settings'
import {
AUTHED,
formOrJson,
HealthResponse,
json,
PlayerSettingEntry,
SettingFormWrite,
SettingJsonWrite,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { App } from './context'
/**
* Player Settings Worker. Serves the small key/value settings bag the game client reads
* on load and writes back as the player toggles options. Backed by a per-player KV map
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
* their first read.
*
* Both routes are auth-gated on the Bearer JWT issued by the `auth` worker.
*/
/**
* Resolve the account id from a Bearer token (the route is auth-gated).
* Returns `null` when the header is missing, the token is invalid, or the `sub`
@@ -72,41 +92,126 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'playersettings', status: 'ok' }))
// Root health check.
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the playersettings worker. No auth.',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'playersettings', status: 'ok' })
)
// The authenticated player's settings as `{ PlayerId, Key, Value }`. Reads
// the per-player KV map; seeds (and persists) the defaults on first read.
.get('/playersettings', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.get(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'The players settings',
description: [
'The authenticated players settings as `{ PlayerId, Key, Value }` entries, read from',
'their KV map. A player with nothing stored is seeded with the reference defaults',
'(Recroom.OOBE, TUTORIAL_COMPLETE_MASK, FIRST_TIME_IN_FLAGS), which are persisted on',
'that first read.',
].join(' '),
security: AUTHED,
responses: {
200: json(PlayerSettingEntry.array(), 'The players settings (defaults on first read)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const kvKey = `player:${id}`
let stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json')
if (!stored || Object.keys(stored).length === 0) {
stored = Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(stored))
const kvKey = `player:${id}`
let stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json')
if (!stored || Object.keys(stored).length === 0) {
stored = Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(stored))
}
return c.json(Object.entries(stored).map(([Key, Value]) => ({ PlayerId: id, Key, Value })))
}
return c.json(Object.entries(stored).map(([Key, Value]) => ({ PlayerId: id, Key, Value })))
})
)
// Upsert player settings into KV, keyed by the authenticated player id.
// A full replace would overwrite the player's entire set; we merge so individual key PUTs
// (e.g. `key=PlayerSessionCount&value=1`) don't wipe the rest.
.put('/playersettings', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.put(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'Write the players settings',
description: [
'Upserts the posted setting(s) into the callers KV map. The write MERGES: a single',
'key PUT (`key=PlayerSessionCount&value=1`, which is what the client sends) leaves the',
'players other settings alone. A JSON body is also accepted, as one object or an',
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
'dropped. An unparseable or empty body is a no-op 200, not a 400. Empty body on success.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
responses: {
200: { description: 'Applied, or nothing parseable to apply (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const incoming = await parseSettings(c)
if (incoming.length === 0) return c.body(null, 200)
const incoming = await parseSettings(c)
if (incoming.length === 0) return c.body(null, 200)
const kvKey = `player:${id}`
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json')
const merged: Record<string, string> = { ...existing }
for (const { key, value } of incoming) merged[key] = value
const kvKey = `player:${id}`
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
kvKey,
'json'
)
const merged: Record<string, string> = { ...existing }
for (const { key, value } of incoming) merged[key] = value
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged))
return c.body(null, 200)
})
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged))
return c.body(null, 200)
}
)
// 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 playersettings',
version: '1.0.0',
description: [
'The player key/value settings bag for recflare, a private-server reimplementation of',
'the Rec Room backend. The client reads these on load and writes them back as the',
'player toggles options; they are stored in a per-player KV map, seeded with the',
'reference defaults on a players first read.',
].join('\n'),
},
servers: [{ url: 'https://playersettings.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
@@ -133,4 +133,38 @@ describe('playersettings endpoints', () => {
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
expect(res.status).toBe(200)
})
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual(['GET /', 'GET /playersettings', 'PUT /playersettings'])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d
// schema used in a response emits a $ref this hono-openapi + zod v4 setup does
// not always hoist, leaving a dangling reference.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})