From 68b98665b2751f72a4dd80cebd06f082cb07e168 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 22 Jul 2026 10:12:41 -0400 Subject: [PATCH] update api docs --- apps/accounts/src/accounts.app.ts | 58 ++++++++-------- apps/api/src/api.app.ts | 72 ++++++++++---------- apps/api/src/test/integration/api.test.ts | 12 ++++ apps/auth/src/auth.app.ts | 58 ++++++++-------- apps/econ/src/econ.app.ts | 60 ++++++++-------- apps/match/src/match.app.ts | 62 +++++++++-------- packages/hono-helpers/src/helpers/openapi.ts | 52 ++++++++++++++ packages/hono-helpers/src/index.ts | 1 + 8 files changed, 225 insertions(+), 150 deletions(-) create mode 100644 packages/hono-helpers/src/helpers/openapi.ts diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index a544309..4476788 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -11,7 +11,7 @@ import { searchAccounts, updateAccount, } 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 { @@ -638,36 +638,38 @@ const app = new Hono() app.get( '/openapi.json', describeRoute({ hide: true }), - openAPIRouteHandler(app, { - documentation: { - info: { - title: 'recflare accounts', - version: '1.0.0', - description: [ - 'Account reads, profile mutations and lookups for recflare, a private-server', - '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', - 'are lenient and reads fall back to a synthesized default account rather than 404.', - 'Nothing in this spec is enforced at runtime — treat a field marked required as "the', - 'client always sends it", not "the server rejects it if absent".', - ].join('\n'), - }, - servers: [{ url: 'https://accounts.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`.', + withCleanSpec( + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare accounts', + version: '1.0.0', + description: [ + 'Account reads, profile mutations and lookups for recflare, a private-server', + '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', + 'are lenient and reads fall back to a synthesized default account rather than 404.', + 'Nothing in this spec is enforced at runtime — treat a field marked required as "the', + 'client always sends it", not "the server rejects it if absent".', + ].join('\n'), + }, + servers: [{ url: 'https://accounts.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 diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 5e89927..e194dc5 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -2,7 +2,7 @@ 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 { avatarRoutes } from './routes/avatar' import { configRoutes } from './routes/config' @@ -58,43 +58,45 @@ const app = new Hono({ strict: false }) app.get( '/openapi.json', describeRoute({ hide: true }), - openAPIRouteHandler(app, { - documentation: { - info: { - title: 'recflare api', - version: '1.0.0', - description: [ - 'The catch-all Game API for recflare, a private-server reimplementation of the Rec', - 'Room backend: everything the client calls that has not been split out into its own', - 'worker yet. Today that is config, the friend graph, inventions, saved photos,', - 'reputation and the assorted sinks the client hits while loading. Relationships,', - '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', - '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', - 'lenient and parse bodies defensively. Nothing in this spec is enforced at runtime —', - 'treat a field marked required as "the client always sends it", not "the server', - 'rejects it if absent".', - ].join('\n'), - }, - servers: [{ url: 'https://api.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`.', + withCleanSpec( + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare api', + version: '1.0.0', + description: [ + 'The catch-all Game API for recflare, a private-server reimplementation of the Rec', + 'Room backend: everything the client calls that has not been split out into its own', + 'worker yet. Today that is config, the friend graph, inventions, saved photos,', + 'reputation and the assorted sinks the client hits while loading. Relationships,', + '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', + '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', + 'lenient and parse bodies defensively. Nothing in this spec is enforced at runtime —', + 'treat a field marked required as "the client always sends it", not "the server', + 'rejects it if absent".', + ].join('\n'), + }, + servers: [{ url: 'https://api.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 diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 8e21b28..6aab923 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1990,4 +1990,16 @@ describe('openapi', () => { const raw = await res.text() 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) + }) }) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index ad61362..af72677 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -18,7 +18,7 @@ import { setPresence, verifyPassword, } 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 { @@ -720,36 +720,38 @@ const app = new Hono() app.get( '/openapi.json', describeRoute({ hide: true }), - openAPIRouteHandler(app, { - documentation: { - info: { - title: 'recflare auth', - version: '1.0.0', - description: [ - '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 handlers are deliberately lenient: missing or malformed fields generally fall', - 'through to a graceful path instead of erroring. Nothing in this spec is enforced at', - 'runtime, so treat a field marked required as "the client always sends it", not "the', - 'server rejects it if absent".', - ].join('\n'), - }, - servers: [{ url: 'https://auth.recflare.net', description: 'Production' }], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'An `access_token` from `POST /connect/token`.', + withCleanSpec( + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare auth', + version: '1.0.0', + description: [ + '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 handlers are deliberately lenient: missing or malformed fields generally fall', + 'through to a graceful path instead of erroring. Nothing in this spec is enforced at', + 'runtime, so treat a field marked required as "the client always sends it", not "the', + 'server rejects it if absent".', + ].join('\n'), + }, + servers: [{ url: 'https://auth.recflare.net', description: 'Production' }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'An `access_token` from `POST /connect/token`.', + }, }, }, }, - }, - }) + }) + ) ) export default app diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 32ca43b..7cabbf2 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' 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' // The notification-type ids the hub carries (owned by the `notify` worker). Imported @@ -1200,37 +1200,39 @@ const app = new Hono({ strict: false }) app.get( '/openapi.json', describeRoute({ hide: true }), - openAPIRouteHandler(app, { - documentation: { - info: { - title: 'recflare econ', - version: '1.0.0', - description: [ - 'Avatar and economy endpoints for recflare, a private-server reimplementation of the', - 'Rec Room backend. The client calls these on the `econ` host; many are also served by', - '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', - 'are lenient and parse bodies defensively. Nothing in this spec is enforced at', - 'runtime — treat a field marked required as "the client always sends it", not "the', - 'server rejects it if absent".', - ].join('\n'), - }, - servers: [{ url: 'https://econ.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`.', + withCleanSpec( + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare econ', + version: '1.0.0', + description: [ + 'Avatar and economy endpoints for recflare, a private-server reimplementation of the', + 'Rec Room backend. The client calls these on the `econ` host; many are also served by', + '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', + 'are lenient and parse bodies defensively. Nothing in this spec is enforced at', + 'runtime — treat a field marked required as "the client always sends it", not "the', + 'server rejects it if absent".', + ].join('\n'), + }, + servers: [{ url: 'https://econ.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 diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 71289ab..8565056 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -23,7 +23,7 @@ import { setPresence, setRoomInstanceInProgress, } from '@repo/domain' -import { withNotFound, withOnError } from '@repo/hono-helpers' +import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' import { @@ -1084,38 +1084,40 @@ async function sweepExpiredPresence(env: Env): Promise { app.get( '/openapi.json', describeRoute({ hide: true }), - openAPIRouteHandler(app, { - documentation: { - info: { - title: 'recflare match', - version: '1.0.0', - description: [ - 'Matchmaking and presence for recflare, a private-server reimplementation of the Rec', - 'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a', - '`room_instance` per session); presence — the instance each player is currently in —', - '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', - 'are lenient and parse bodies defensively. Nothing in this spec is enforced at', - 'runtime — treat a field marked required as "the client always sends it", not "the', - 'server rejects it if absent".', - ].join('\n'), - }, - servers: [{ url: 'https://match.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`.', + withCleanSpec( + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare match', + version: '1.0.0', + description: [ + 'Matchmaking and presence for recflare, a private-server reimplementation of the Rec', + 'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a', + '`room_instance` per session); presence — the instance each player is currently in —', + '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', + 'are lenient and parse bodies defensively. Nothing in this spec is enforced at', + 'runtime — treat a field marked required as "the client always sends it", not "the', + 'server rejects it if absent".', + ].join('\n'), + }, + servers: [{ url: 'https://match.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`.', + }, }, }, }, - }, - }) + }) + ) ) // The HTTP surface is a standard Hono app, exported by name so it can be mounted diff --git a/packages/hono-helpers/src/helpers/openapi.ts b/packages/hono-helpers/src/helpers/openapi.ts new file mode 100644 index 0000000..ee92c43 --- /dev/null +++ b/packages/hono-helpers/src/helpers/openapi.ts @@ -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 + 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) + } +} diff --git a/packages/hono-helpers/src/index.ts b/packages/hono-helpers/src/index.ts index 310891a..f392aa3 100644 --- a/packages/hono-helpers/src/index.ts +++ b/packages/hono-helpers/src/index.ts @@ -3,6 +3,7 @@ export * from './helpers/env' export { logger } from './helpers/logger' export { getRequestLogData, type LogDataRequest } from './helpers/request' export * from './helpers/errors' +export * from './helpers/openapi' export * from './helpers/url' export * from './middleware/withCache' export * from './middleware/withDefaultCors'