mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
updating api docs
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
),
|
||||
])
|
||||
@@ -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 player’s settings',
|
||||
description: [
|
||||
'The authenticated player’s 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 player’s 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 player’s settings',
|
||||
description: [
|
||||
'Upserts the posted setting(s) into the caller’s KV map. The write MERGES: a single',
|
||||
'key PUT (`key=PlayerSessionCount&value=1`, which is what the client sends) leaves the',
|
||||
'player’s 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 player’s 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 worker’s `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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user