Files
recflare/apps/chat/src/openapi.ts
T
2026-07-22 11:43:30 -04:00

235 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the chat 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 form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** 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: [] }]
/**
* The 404 a thread-scoped route answers when the caller isn't a member. Deliberately
* indistinguishable from "no such thread" — whether a thread exists is itself private.
*/
export const NOT_A_MEMBER_RESPONSE = {
description: 'Not a member of the thread (or no such thread) — the two are indistinguishable',
}
// ---- Response schemas ------------------------------------------------------
/**
* A chat message as stored and served (see message-db.ts). `contents` is the client's own
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) — stored verbatim and served back
* untouched, so new message types need no schema change. A `senderPlayerId` of -5 is the
* system pseudo-player the "started a chat" / "left" notices are posted as.
*/
export const ChatMessageDto = z.object({
chatMessageId: z.int().describe('Server-assigned, unique across all threads'),
chatThreadId: z.int(),
senderPlayerId: z.int().describe('-5 is the system sender (join/leave notices)'),
timeSent: z.string().describe('ISO-8601 UTC instant, as .NET serializes DateTime'),
contents: z.string().describe('The raw client envelope, e.g. {"Type":0,"Version":1,"Data":"hi"}'),
moderationState: z.int().describe('0 None, 1 Flagged, 2 Hidden'),
})
/** The per-viewer fields every rendered thread carries, plus the thread's own. */
const threadBase = {
chatThreadId: z.int(),
playerIds: z.array(z.int()).describe('The threads members, ordered by id'),
lastReadMessageId: z
.int()
.describe('0 when never read — never null (the client deserializes a non-nullable int)'),
chatThreadName: z
.string()
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
isFavorited: z.boolean(),
}
/**
* A thread as it appears in the thread LIST: the thread, its members, the caller's own
* read/snooze/favorite state, and its single most recent message.
*/
export const ChatThreadDto = z.object({
latestMessage: ChatMessageDto.nullable().describe('Null only for a thread with no messages yet'),
...threadBase,
})
/**
* A thread as it appears when a conversation is OPENED: the same fields, but with a page
* of `messages` (newest first) in place of `latestMessage`. The client is sent one or the
* other, never both; `messages` is always present, empty for a brand-new thread.
*/
export const ChatThreadWithMessagesDto = z.object({
...threadBase,
messages: z.array(ChatMessageDto).describe('Newest first; empty for a thread with nothing in it'),
})
/**
* The bare ChatResult integer several actions answer with (HTTP 200 either way): 0
* success, 1 invalid arguments, 3 membership not found (which doubles as "no such
* thread"), 4 player already on the thread.
*/
export const ChatResult = z
.int()
.describe('0 success · 1 invalid arguments · 3 membership not found · 4 already on thread')
/**
* `POST /thread` — the reference's wrapper: the created (or resolved) thread plus the
* result of the first message. Blank `messageContents` opens the thread without posting
* and reports invalid-arguments (1), still with the thread attached.
*/
export const CreateThreadResponse = z.object({
chatThread: ChatThreadDto,
chatResult: ChatResult,
})
/**
* `POST /thread/:id` and `/thread/:id/message` — the whole thread with its messages, not
* just the message that was sent, so the client re-renders the conversation from one
* response.
*/
export const SendMessageResponse = z.object({
chatResult: ChatResult,
chatThread: ChatThreadWithMessagesDto.nullable(),
})
/** `GET /` — the liveness probe. */
export const ServiceStatus = z.object({
service: z.literal('chat'),
status: z.literal('ok'),
})
// ---- Request schemas -------------------------------------------------------
/**
* `POST /thread` form body. `ids` is repeated (`ids=2&ids=155`) and names the OTHER
* members; the caller is always added. Values that aren't integers are dropped. The
* fields are also read from the query string, since the same call is easy to hand-write
* that way.
*/
export const CreateThreadRequest = z.object({
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
messageContents: z
.string()
.optional()
.describe(
[
'The client envelope, stored verbatim and unparsed. Blank/absent opens the thread',
'without posting a message and reports chatResult 1',
].join(' ')
),
})
/**
* `POST /thread/withmembers` form body — the client's GetChatBetweenPlayers. Same
* repeated `ids`, plus the page size for the returned `messages`.
*/
export const WithMembersRequest = z.object({
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
messageCount: z
.int()
.optional()
.describe('Page size for `messages`; defaults to 50, capped at 100'),
})
/** `POST /thread/:id` (and `/thread/:id/message`) form body. */
export const SendMessageRequest = z.object({
messageContents: z
.string()
.describe(
[
'The client envelope (Type/Version/Data), stored verbatim. Blank or missing stores',
'nothing and reports chatResult 1, still with the thread attached',
].join(' ')
),
messageCount: z.int().optional().describe('Page size for the returned threads `messages`'),
})
/** `POST|PUT /thread/:id/rename` form body. Any member may rename; there is no owner. */
export const RenameThreadRequest = z.object({
name: z
.string()
.describe('Truncated to 128 chars, not rejected. Empty clears it back to unnamed'),
})
/** `POST|PUT /thread/:id/snooze` form body. */
export const SnoozeThreadRequest = z.object({
snooze: z
.string()
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
/** `PUT|POST /thread/:id/favorite` form body. */
export const FavoriteThreadRequest = z.object({
favorite: z
.string()
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
// ---- Shared parameters -----------------------------------------------------
/** The numeric `:id` path segment naming a thread (constrained to digits by the route). */
export const THREAD_ID_PARAM = {
name: 'id',
in: 'path',
required: true,
description: 'Chat thread id (digits only — a non-numeric path matches no route)',
schema: { type: 'string' },
} as const
/** The `MessageCount` / `messageCount` query param the GET routes accept. */
export function messageCountParam(fallback: number) {
return {
name: 'MessageCount',
in: 'query',
required: false,
description: `Page size; defaults to ${fallback}, capped at 100. \`messageCount\` is accepted too. Anything unparseable or out of range falls back rather than 400ing`,
schema: { type: 'integer' },
} as const
}