update api docs

This commit is contained in:
Devin Zuczek
2026-07-22 10:12:41 -04:00
parent 881663a4fe
commit 68b98665b2
8 changed files with 225 additions and 150 deletions
+30 -28
View File
@@ -11,7 +11,7 @@ import {
searchAccounts, searchAccounts,
updateAccount, updateAccount,
} from '@repo/domain' } from '@repo/domain'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { import {
@@ -638,36 +638,38 @@ const app = new Hono<App>()
app.get( app.get(
'/openapi.json', '/openapi.json',
describeRoute({ hide: true }), describeRoute({ hide: true }),
openAPIRouteHandler(app, { withCleanSpec(
documentation: { openAPIRouteHandler(app, {
info: { documentation: {
title: 'recflare accounts', info: {
version: '1.0.0', title: 'recflare accounts',
description: [ version: '1.0.0',
'Account reads, profile mutations and lookups for recflare, a private-server', description: [
'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`', 'Account reads, profile mutations and lookups for recflare, a private-server',
'D1 database, whose `account` schema is owned by the `auth` worker.', 'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`',
'', 'D1 database, whose `account` schema is owned by the `auth` worker.',
'The shapes here are **reverse-engineered from the game client**, which is the only', '',
'real consumer. They record observed behaviour, not a designed contract; the handlers', 'The shapes here are **reverse-engineered from the game client**, which is the only',
'are lenient and reads fall back to a synthesized default account rather than 404.', 'real consumer. They record observed behaviour, not a designed contract; the handlers',
'Nothing in this spec is enforced at runtime — treat a field marked required as "the', 'are lenient and reads fall back to a synthesized default account rather than 404.',
'client always sends it", not "the server rejects it if absent".', 'Nothing in this spec is enforced at runtime — treat a field marked required as "the',
].join('\n'), 'client always sends it", not "the server rejects it if absent".',
}, ].join('\n'),
servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }], },
components: { servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }],
securitySchemes: { components: {
bearerAuth: { securitySchemes: {
type: 'http', bearerAuth: {
scheme: 'bearer', type: 'http',
bearerFormat: 'JWT', scheme: 'bearer',
description: 'An `access_token` from the auth workers `POST /connect/token`.', bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
}, },
}, },
}, },
}, })
}) )
) )
export default app export default app
+37 -35
View File
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { avatarRoutes } from './routes/avatar' import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config' import { configRoutes } from './routes/config'
@@ -58,43 +58,45 @@ const app = new Hono<App>({ strict: false })
app.get( app.get(
'/openapi.json', '/openapi.json',
describeRoute({ hide: true }), describeRoute({ hide: true }),
openAPIRouteHandler(app, { withCleanSpec(
documentation: { openAPIRouteHandler(app, {
info: { documentation: {
title: 'recflare api', info: {
version: '1.0.0', title: 'recflare api',
description: [ version: '1.0.0',
'The catch-all Game API for recflare, a private-server reimplementation of the Rec', description: [
'Room backend: everything the client calls that has not been split out into its own', 'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
'worker yet. Today that is config, the friend graph, inventions, saved photos,', 'Room backend: everything the client calls that has not been split out into its own',
'reputation and the assorted sinks the client hits while loading. Relationships,', 'worker yet. Today that is config, the friend graph, inventions, saved photos,',
'inventions and images are D1-backed; several endpoints are still stubs, noted per', 'reputation and the assorted sinks the client hits while loading. Relationships,',
'route.', 'inventions and images are D1-backed; several endpoints are still stubs, noted per',
'', 'route.',
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,', '',
'equipment, consumables and objectives on `econ`) are already served there — the', 'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'client calls that host and the copy here is a stub, which each route says.', 'equipment, consumables and objectives on `econ`) are already served there — the',
'', 'client calls that host and the copy here is a stub, which each route says.',
'The shapes are **reverse-engineered from the game client**, which is the only real', '',
'consumer. They record observed behaviour, not a designed contract; the handlers are', 'The shapes are **reverse-engineered from the game client**, which is the only real',
'lenient and parse bodies defensively. Nothing in this spec is enforced at runtime —', 'consumer. They record observed behaviour, not a designed contract; the handlers are',
'treat a field marked required as "the client always sends it", not "the server', 'lenient and parse bodies defensively. Nothing in this spec is enforced at runtime —',
'rejects it if absent".', 'treat a field marked required as "the client always sends it", not "the server',
].join('\n'), 'rejects it if absent".',
}, ].join('\n'),
servers: [{ url: 'https://api.recflare.net', description: 'Production' }], },
components: { servers: [{ url: 'https://api.recflare.net', description: 'Production' }],
securitySchemes: { components: {
bearerAuth: { securitySchemes: {
type: 'http', bearerAuth: {
scheme: 'bearer', type: 'http',
bearerFormat: 'JWT', scheme: 'bearer',
description: 'An `access_token` from the auth workers `POST /connect/token`.', bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
}, },
}, },
}, },
}, })
}) )
) )
export default app export default app
+12
View File
@@ -1990,4 +1990,16 @@ describe('openapi', () => {
const raw = await res.text() const raw = await res.text()
expect(raw.match(/\$ref/g)).toBeNull() expect(raw.match(/\$ref/g)).toBeNull()
}) })
// `z.int()` carries the safe-integer range as its bounds, which Scalar would
// otherwise show as the example value for every integer field (-9007199254740991).
// withCleanSpec() supplies a placeholder instead; this guards the wrapper staying
// wired up.
test('integer fields carry a placeholder example', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const raw = await res.text()
const integers = raw.match(/"type":"integer"/g) ?? []
expect(integers.length).toBeGreaterThan(0)
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
})
}) })
+30 -28
View File
@@ -18,7 +18,7 @@ import {
setPresence, setPresence,
verifyPassword, verifyPassword,
} from '@repo/domain' } from '@repo/domain'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { import {
@@ -720,36 +720,38 @@ const app = new Hono<App>()
app.get( app.get(
'/openapi.json', '/openapi.json',
describeRoute({ hide: true }), describeRoute({ hide: true }),
openAPIRouteHandler(app, { withCleanSpec(
documentation: { openAPIRouteHandler(app, {
info: { documentation: {
title: 'recflare auth', info: {
version: '1.0.0', title: 'recflare auth',
description: [ version: '1.0.0',
'Authentication and token issuance for recflare, a private-server reimplementation', description: [
'of the Rec Room backend.', 'Authentication and token issuance for recflare, a private-server reimplementation',
'', 'of the Rec Room backend.',
'The shapes here are **reverse-engineered from the game client**, which is the only', '',
'real consumer. They record observed behaviour rather than a designed contract, and', 'The shapes here are **reverse-engineered from the game client**, which is the only',
'the handlers are deliberately lenient: missing or malformed fields generally fall', 'real consumer. They record observed behaviour rather than a designed contract, and',
'through to a graceful path instead of erroring. Nothing in this spec is enforced at', 'the handlers are deliberately lenient: missing or malformed fields generally fall',
'runtime, so treat a field marked required as "the client always sends it", not "the', 'through to a graceful path instead of erroring. Nothing in this spec is enforced at',
'server rejects it if absent".', 'runtime, so treat a field marked required as "the client always sends it", not "the',
].join('\n'), 'server rejects it if absent".',
}, ].join('\n'),
servers: [{ url: 'https://auth.recflare.net', description: 'Production' }], },
components: { servers: [{ url: 'https://auth.recflare.net', description: 'Production' }],
securitySchemes: { components: {
bearerAuth: { securitySchemes: {
type: 'http', bearerAuth: {
scheme: 'bearer', type: 'http',
bearerFormat: 'JWT', scheme: 'bearer',
description: 'An `access_token` from `POST /connect/token`.', bearerFormat: 'JWT',
description: 'An `access_token` from `POST /connect/token`.',
},
}, },
}, },
}, },
}, })
}) )
) )
export default app export default app
+31 -29
View File
@@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain' import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported // The notification-type ids the hub carries (owned by the `notify` worker). Imported
@@ -1200,37 +1200,39 @@ const app = new Hono<App>({ strict: false })
app.get( app.get(
'/openapi.json', '/openapi.json',
describeRoute({ hide: true }), describeRoute({ hide: true }),
openAPIRouteHandler(app, { withCleanSpec(
documentation: { openAPIRouteHandler(app, {
info: { documentation: {
title: 'recflare econ', info: {
version: '1.0.0', title: 'recflare econ',
description: [ version: '1.0.0',
'Avatar and economy endpoints for recflare, a private-server reimplementation of the', description: [
'Rec Room backend. The client calls these on the `econ` host; many are also served by', 'Avatar and economy endpoints for recflare, a private-server reimplementation of the',
'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,', 'Rec Room backend. The client calls these on the `econ` host; many are also served by',
'inventory, consumables, saved outfits and gift boxes are D1-backed.', 'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
'', 'inventory, consumables, saved outfits and gift boxes are D1-backed.',
'The shapes here are **reverse-engineered from the game client**, which is the only', '',
'real consumer. They record observed behaviour, not a designed contract; the handlers', 'The shapes here are **reverse-engineered from the game client**, which is the only',
'are lenient and parse bodies defensively. Nothing in this spec is enforced at', 'real consumer. They record observed behaviour, not a designed contract; the handlers',
'runtime — treat a field marked required as "the client always sends it", not "the', 'are lenient and parse bodies defensively. Nothing in this spec is enforced at',
'server rejects it if absent".', 'runtime — treat a field marked required as "the client always sends it", not "the',
].join('\n'), 'server rejects it if absent".',
}, ].join('\n'),
servers: [{ url: 'https://econ.recflare.net', description: 'Production' }], },
components: { servers: [{ url: 'https://econ.recflare.net', description: 'Production' }],
securitySchemes: { components: {
bearerAuth: { securitySchemes: {
type: 'http', bearerAuth: {
scheme: 'bearer', type: 'http',
bearerFormat: 'JWT', scheme: 'bearer',
description: 'An `access_token` from the auth workers `POST /connect/token`.', bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
}, },
}, },
}, },
}, })
}) )
) )
export default app export default app
+32 -30
View File
@@ -23,7 +23,7 @@ import {
setPresence, setPresence,
setRoomInstanceInProgress, setRoomInstanceInProgress,
} from '@repo/domain' } from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { import {
@@ -1084,38 +1084,40 @@ async function sweepExpiredPresence(env: Env): Promise<void> {
app.get( app.get(
'/openapi.json', '/openapi.json',
describeRoute({ hide: true }), describeRoute({ hide: true }),
openAPIRouteHandler(app, { withCleanSpec(
documentation: { openAPIRouteHandler(app, {
info: { documentation: {
title: 'recflare match', info: {
version: '1.0.0', title: 'recflare match',
description: [ version: '1.0.0',
'Matchmaking and presence for recflare, a private-server reimplementation of the Rec', description: [
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a', 'Matchmaking and presence for recflare, a private-server reimplementation of the Rec',
'`room_instance` per session); presence — the instance each player is currently in —', 'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears', '`room_instance` per session); presence — the instance each player is currently in —',
'expired presence and frees up instances a crashed player never left.', 'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
'', 'expired presence and frees up instances a crashed player never left.',
'The shapes here are **reverse-engineered from the game client**, which is the only', '',
'real consumer. They record observed behaviour, not a designed contract; the handlers', 'The shapes here are **reverse-engineered from the game client**, which is the only',
'are lenient and parse bodies defensively. Nothing in this spec is enforced at', 'real consumer. They record observed behaviour, not a designed contract; the handlers',
'runtime — treat a field marked required as "the client always sends it", not "the', 'are lenient and parse bodies defensively. Nothing in this spec is enforced at',
'server rejects it if absent".', 'runtime — treat a field marked required as "the client always sends it", not "the',
].join('\n'), 'server rejects it if absent".',
}, ].join('\n'),
servers: [{ url: 'https://match.recflare.net', description: 'Production' }], },
components: { servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
securitySchemes: { components: {
bearerAuth: { securitySchemes: {
type: 'http', bearerAuth: {
scheme: 'bearer', type: 'http',
bearerFormat: 'JWT', scheme: 'bearer',
description: 'An `access_token` from the auth workers `POST /connect/token`.', bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
}, },
}, },
}, },
}, })
}) )
) )
// The HTTP surface is a standard Hono app, exported by name so it can be mounted // The HTTP surface is a standard Hono app, exported by name so it can be mounted
@@ -0,0 +1,52 @@
import type { Handler, MiddlewareHandler } from 'hono'
/**
* Zod 4 encodes `z.int()` as `{ type: 'integer', minimum: -9007199254740991, maximum:
* 9007199254740991 }` — the safe-integer range. That is accurate, but Scalar (and most
* spec viewers) derive the displayed example from `minimum` when a schema carries no
* `example` of its own, so every integer field in the docs rendered as
* `-9007199254740991`.
*
* The bounds are left alone; we just supply a neutral placeholder so the viewer has
* something better to show.
*/
const PLACEHOLDER_INTEGER = 12345
/** Recursively add a placeholder example to integer schemas that lack one. */
function addIntegerExamples(node: unknown): void {
if (Array.isArray(node)) {
for (const item of node) addIntegerExamples(item)
return
}
if (node === null || typeof node !== 'object') return
const obj = node as Record<string, unknown>
if (obj.type === 'integer' && obj.example === undefined && obj.examples === undefined) {
// Don't contradict a schema that really is narrow (`z.int().max(10)`, an enum-ish
// range) — the placeholder only goes in where it's a legal value.
const min = obj.minimum
const max = obj.maximum
const tooLow = typeof min === 'number' && PLACEHOLDER_INTEGER < min
const tooHigh = typeof max === 'number' && PLACEHOLDER_INTEGER > max
if (!tooLow && !tooHigh) obj.example = PLACEHOLDER_INTEGER
}
for (const value of Object.values(obj)) addIntegerExamples(value)
}
/**
* Wrap `openAPIRouteHandler(...)` so the generated document gets example values for its
* integer fields. Purely cosmetic — nothing about the documented shapes changes.
*
* ```ts
* app.get('/openapi.json', describeRoute({ hide: true }), withCleanSpec(openAPIRouteHandler(app, { ... })))
* ```
*/
export function withCleanSpec(handler: Handler | MiddlewareHandler): Handler {
return async (c, next) => {
const res = await (handler as Handler)(c, next)
if (!(res instanceof Response)) return res as never
const spec: unknown = await res.json()
addIntegerExamples(spec)
return c.json(spec as Record<string, unknown>)
}
}
+1
View File
@@ -3,6 +3,7 @@ export * from './helpers/env'
export { logger } from './helpers/logger' export { logger } from './helpers/logger'
export { getRequestLogData, type LogDataRequest } from './helpers/request' export { getRequestLogData, type LogDataRequest } from './helpers/request'
export * from './helpers/errors' export * from './helpers/errors'
export * from './helpers/openapi'
export * from './helpers/url' export * from './helpers/url'
export * from './middleware/withCache' export * from './middleware/withCache'
export * from './middleware/withDefaultCors' export * from './middleware/withDefaultCors'