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:
+494
-127
@@ -1,11 +1,34 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import { getThreadMessages } from './message-db'
|
||||
import {
|
||||
AUTHED,
|
||||
ChatMessageDto,
|
||||
ChatResult,
|
||||
ChatThreadDto,
|
||||
ChatThreadWithMessagesDto,
|
||||
CreateThreadRequest,
|
||||
CreateThreadResponse,
|
||||
FavoriteThreadRequest,
|
||||
form,
|
||||
json,
|
||||
messageCountParam,
|
||||
NOT_A_MEMBER_RESPONSE,
|
||||
RenameThreadRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
ServiceStatus,
|
||||
SnoozeThreadRequest,
|
||||
THREAD_ID_PARAM,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
WithMembersRequest,
|
||||
} from './openapi'
|
||||
import {
|
||||
addThreadMember,
|
||||
getOrCreateThreadWithMembers,
|
||||
@@ -218,6 +241,74 @@ async function formField(c: Context<App>, name: string): Promise<string | undefi
|
||||
return typeof value === 'string' ? value : c.req.query(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
|
||||
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze, favorite,
|
||||
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
|
||||
* and the "3 when the caller isn't on the thread" behaviour.
|
||||
*/
|
||||
function chatResultRoute(
|
||||
summary: string,
|
||||
description: string,
|
||||
extra: {
|
||||
requestBody?: ReturnType<typeof form>
|
||||
parameters?: unknown[]
|
||||
successDescription?: string
|
||||
/** Set for the read-pointer routes, which 404 a non-member instead of answering 3. */
|
||||
notFound?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return describeRoute({
|
||||
tags: ['Chat'],
|
||||
summary,
|
||||
description,
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM, ...((extra.parameters ?? []) as never[])],
|
||||
...(extra.requestBody === undefined ? {} : { requestBody: extra.requestBody }),
|
||||
responses: {
|
||||
200: json(
|
||||
ChatResult,
|
||||
extra.successDescription ??
|
||||
'The ChatResult (0 on success, 3 when the caller isn’t on the thread)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
...(extra.notFound === true ? { 404: NOT_A_MEMBER_RESPONSE } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `describeRoute` spec shared by the two spellings of "send to an existing thread".
|
||||
* `/thread/{id}` is what the client posts; `/thread/{id}/message` is the same call under
|
||||
* the reference's other spelling, and both land in `sendToThread`.
|
||||
*/
|
||||
function sendToThreadRoute(spelling: string) {
|
||||
return describeRoute({
|
||||
tags: ['Messages'],
|
||||
summary: `Send a message to an existing thread (${spelling})`,
|
||||
description: [
|
||||
'Every message after the one that opened the conversation. Answers',
|
||||
'`{ chatResult, chatThread }` — the WHOLE thread with its messages, not just the message',
|
||||
'that was sent, so the client re-renders the conversation from one response. Blank or',
|
||||
'missing `messageContents` stores nothing and reports invalid-arguments (1), still with',
|
||||
'the thread attached, rather than an error status. Sending is reading: the sender’s own',
|
||||
'`lastReadMessageId` comes back already at the message just posted. Pushes',
|
||||
'ChatMessageReceived to every member, the sender included — the client doesn’t fold the',
|
||||
'HTTP response into its local cache, so without a self-targeted push its own outgoing',
|
||||
'message doesn’t appear until the thread is refetched. Note the hub frame’s `Id` is a',
|
||||
'STRING: the client dispatches on it and silently drops a numeric one.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM],
|
||||
requestBody: form(SendMessageRequest, 'The message envelope'),
|
||||
responses: {
|
||||
200: json(SendMessageResponse, 'The ChatResult plus the whole thread with its messages'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: NOT_A_MEMBER_RESPONSE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -232,17 +323,45 @@ const app = new Hono<App>()
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
.get('/', (c) => c.json({ service: 'chat', status: 'ok' }))
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Service'],
|
||||
summary: 'Service liveness',
|
||||
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
|
||||
responses: { 200: json(ServiceStatus, 'Always `{ service: "chat", status: "ok" }`') },
|
||||
}),
|
||||
(c) => c.json({ service: 'chat', status: 'ok' })
|
||||
)
|
||||
|
||||
// The player's own thread list, newest conversation first — each thread carrying its
|
||||
// latest message and the caller's own read/snooze/favorite state. `MessageCount` is
|
||||
// the page size (of threads, despite the name). Membership scopes the query, so a
|
||||
// player only ever sees their own threads.
|
||||
.get('/thread', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) }))
|
||||
})
|
||||
.get(
|
||||
'/thread',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'The caller’s thread list',
|
||||
description: [
|
||||
'Every thread the caller is a member of, newest conversation first — each carrying its',
|
||||
'`latestMessage` and the caller’s own read/snooze/favorite state. `MessageCount` is the',
|
||||
'page size (of THREADS, despite the name). Membership scopes the query, so a player',
|
||||
'only ever sees their own threads.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [messageCountParam(DEFAULT_MESSAGE_COUNT)],
|
||||
responses: {
|
||||
200: json(ChatThreadDto.array(), 'The caller’s threads, newest first (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) }))
|
||||
}
|
||||
)
|
||||
|
||||
// Send to a set of players (`ids=155&ids=2&messageContents=…`) — the client's
|
||||
// create-thread-and-post-first-message call, in one. Resolves to the thread those
|
||||
@@ -252,33 +371,60 @@ const app = new Hono<App>()
|
||||
// (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client
|
||||
// also sends it blank, right after /thread/withmembers: that opens the thread without
|
||||
// posting an empty message, and reports invalid-arguments the way the reference does.
|
||||
.post('/thread', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/thread',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'Open a thread with a set of players and post the first message',
|
||||
description: [
|
||||
'The client’s create-thread-and-post-first-message call, in one. Resolves to the thread',
|
||||
'those players already share rather than opening a second one. `messageContents` is the',
|
||||
'same envelope a message carries and is stored verbatim, unparsed; the client also sends',
|
||||
'it blank right after `/thread/withmembers`, which opens the thread without posting and',
|
||||
'reports invalid-arguments. Answers a `{ chatThread, chatResult }` wrapper, not a bare',
|
||||
'thread. Pushes ChatMessageReceived to every member (including the sender).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateThreadRequest, 'The member ids and the first message'),
|
||||
responses: {
|
||||
200: json(CreateThreadResponse, 'The thread plus the result of the first message'),
|
||||
400: {
|
||||
description: [
|
||||
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
|
||||
'(empty body)',
|
||||
].join(' '),
|
||||
},
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||
|
||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
|
||||
const contents = (await formField(c, 'messageContents'))?.trim()
|
||||
const posted =
|
||||
contents === undefined || contents === ''
|
||||
? null
|
||||
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
|
||||
if (posted !== null) {
|
||||
await pushChatMessage(c, posted)
|
||||
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
|
||||
const contents = (await formField(c, 'messageContents'))?.trim()
|
||||
const posted =
|
||||
contents === undefined || contents === ''
|
||||
? null
|
||||
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
|
||||
if (posted !== null) {
|
||||
await pushChatMessage(c, posted)
|
||||
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
|
||||
}
|
||||
|
||||
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
|
||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
||||
// The reference answers a wrapper here, not a bare thread.
|
||||
return c.json({
|
||||
chatThread: thread,
|
||||
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
|
||||
})
|
||||
}
|
||||
|
||||
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
|
||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
||||
// The reference answers a wrapper here, not a bare thread.
|
||||
return c.json({
|
||||
chatThread: thread,
|
||||
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or
|
||||
// create: the thread whose membership is exactly `ids` plus the caller, opened only
|
||||
@@ -288,21 +434,48 @@ const app = new Hono<App>()
|
||||
// Answers the thread with a `messages` array (what `messageCount` sizes) rather than
|
||||
// the list's single `latestMessage`, so the client can open straight into the
|
||||
// conversation. The array is always present, empty for a brand-new thread.
|
||||
.post('/thread/withmembers', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/thread/withmembers',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'Fetch or open the thread with exactly these members',
|
||||
description: [
|
||||
'The client’s GetChatBetweenPlayers. Fetch-or-create: the thread whose membership is',
|
||||
'exactly `ids` plus the caller, opened only if they don’t already share one (returning a',
|
||||
'fresh empty thread each call would bury the real conversation). Answers the thread with',
|
||||
'a `messages` array — what `messageCount` sizes — rather than the list’s single',
|
||||
'`latestMessage`, so the client can open straight into the conversation. The array is',
|
||||
'always present, empty for a brand-new thread.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(WithMembersRequest, 'The member ids and the page size'),
|
||||
responses: {
|
||||
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
|
||||
400: {
|
||||
description: [
|
||||
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
|
||||
'(empty body)',
|
||||
].join(' '),
|
||||
},
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||
// A thread needs someone else in it; naming only yourself is a bad request
|
||||
// rather than a lonely thread.
|
||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||
// A thread needs someone else in it; naming only yourself is a bad request
|
||||
// rather than a lonely thread.
|
||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||
|
||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
||||
return c.json(thread)
|
||||
})
|
||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
||||
return c.json(thread)
|
||||
}
|
||||
)
|
||||
|
||||
// A page of one thread's messages, newest first — a bare array, not a thread object.
|
||||
// The client reads a conversation through either spelling: `/thread/2?messageCount=50`
|
||||
@@ -320,15 +493,36 @@ const app = new Hono<App>()
|
||||
//
|
||||
// 404s only for a thread the caller isn't in, not for one that's simply empty: a
|
||||
// thread just opened with someone has no messages yet and still has to open.
|
||||
.get('/thread/:id{[0-9]+}', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.get(
|
||||
'/thread/:id{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'One thread with its recent messages',
|
||||
description: [
|
||||
'What the client opens a conversation with (`/thread/13?messageCount=50`). An OBJECT —',
|
||||
'the same shape `/thread/withmembers` answers: the client parses this one as a thread',
|
||||
"and rejects a bare array (\"expected '{', actual '['\"). Only `/thread/{id}/message`",
|
||||
'serves an array. 404s only for a thread the caller isn’t in, not for one that’s simply',
|
||||
'empty — a thread just opened with someone has no messages yet and still has to open.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_THREAD_MESSAGE_COUNT)],
|
||||
responses: {
|
||||
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: NOT_A_MEMBER_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||
return thread === null ? c.notFound() : c.json(thread)
|
||||
})
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||
return thread === null ? c.notFound() : c.json(thread)
|
||||
}
|
||||
)
|
||||
|
||||
// Send a message to a thread that already exists — every message after the one that
|
||||
// opened the conversation. `/thread/18` is what the client posts; `/thread/18/message`
|
||||
@@ -337,25 +531,40 @@ const app = new Hono<App>()
|
||||
// Answers the SendMessageResponse wrapper (`{chatMessage, chatResult}`), not a bare
|
||||
// message. Blank or missing contents is invalid-arguments with no message attached,
|
||||
// rather than an error status.
|
||||
.post('/thread/:id{[0-9]+}', (c) => sendToThread(c))
|
||||
.post('/thread/:id{[0-9]+}/message', (c) => sendToThread(c))
|
||||
.post('/thread/:id{[0-9]+}', sendToThreadRoute('`/thread/{id}`'), (c) => sendToThread(c))
|
||||
.post('/thread/:id{[0-9]+}/message', sendToThreadRoute('`/thread/{id}/message`'), (c) =>
|
||||
sendToThread(c)
|
||||
)
|
||||
|
||||
// Rename a thread (`name=my chat`). Any member may rename — there's no owner — and an
|
||||
// empty name clears it back to unnamed, which renders as the member list. Answers a
|
||||
// bare ChatResult: 3 when the caller isn't on the thread, 0 on success.
|
||||
.on(['POST', 'PUT'], '/thread/:id{[0-9]+}/rename', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.on(
|
||||
['POST', 'PUT'],
|
||||
'/thread/:id{[0-9]+}/rename',
|
||||
chatResultRoute(
|
||||
'Rename a thread',
|
||||
[
|
||||
'Any member may rename — there is no owner — and an empty name clears it back to unnamed,',
|
||||
'which renders as the member list. The name is truncated to 128 characters rather than',
|
||||
'rejected. Answers a bare ChatResult: 3 when the caller isn’t on the thread, 0 on success.',
|
||||
].join(' '),
|
||||
{ requestBody: form(RenameThreadRequest, 'The new name') }
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH)
|
||||
await setThreadName(c.env.DB, chatThreadId, name)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
|
||||
const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH)
|
||||
await setThreadName(c.env.DB, chatThreadId, name)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
})
|
||||
)
|
||||
|
||||
// Leave a thread. The thread and its history survive — only the caller's membership
|
||||
// goes, so they stop seeing it and the remaining members keep the conversation.
|
||||
@@ -363,25 +572,39 @@ const app = new Hono<App>()
|
||||
// A "Player <@U…> left" notice is posted first, so the others see why the roster
|
||||
// changed; the leaver is still a member at that moment and gets the push too, which
|
||||
// is what tells their client the thread is gone.
|
||||
.on(['POST', 'DELETE'], '/thread/:id{[0-9]+}/leave', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.on(
|
||||
['POST', 'DELETE'],
|
||||
'/thread/:id{[0-9]+}/leave',
|
||||
chatResultRoute(
|
||||
'Leave a thread',
|
||||
[
|
||||
'The thread and its history survive — only the caller’s membership goes, so they stop',
|
||||
'seeing it and the remaining members keep the conversation. A "Player <@U…> left" system',
|
||||
'notice is posted first so the others see why the roster changed; the leaver is still a',
|
||||
'member at that moment and gets the push too, which is what tells their client the thread',
|
||||
'is gone.',
|
||||
].join(' ')
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
const notice = await postMessage(c.env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: leftChatContents(id),
|
||||
})
|
||||
await pushChatMessage(c, notice)
|
||||
|
||||
await removeThreadMember(c.env.DB, chatThreadId, id)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
|
||||
const notice = await postMessage(c.env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: leftChatContents(id),
|
||||
})
|
||||
await pushChatMessage(c, notice)
|
||||
|
||||
await removeThreadMember(c.env.DB, chatThreadId, id)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
})
|
||||
)
|
||||
|
||||
// Snooze or unsnooze a thread (`snooze=True`), for the caller alone — snoozing is a
|
||||
// per-member setting, so it never affects what anyone else sees.
|
||||
@@ -390,34 +613,60 @@ const app = new Hono<App>()
|
||||
// `True` is therefore stored as a far-future instant meaning "muted indefinitely", and
|
||||
// `False` clears it. If the real server instead snoozes for a fixed window, this is
|
||||
// the one line to change.
|
||||
.on(['POST', 'PUT'], '/thread/:id{[0-9]+}/snooze', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.on(
|
||||
['POST', 'PUT'],
|
||||
'/thread/:id{[0-9]+}/snooze',
|
||||
chatResultRoute(
|
||||
'Snooze or unsnooze a thread',
|
||||
[
|
||||
'Per-member, for the caller alone — it never affects what anyone else sees. The client',
|
||||
'sends a boolean while the field it reads back (`snoozedUntil`) is a time, so `True` is',
|
||||
'stored as a far-future instant (9999-12-31T23:59:59Z) meaning "muted indefinitely" and',
|
||||
'`False` clears it.',
|
||||
].join(' '),
|
||||
{ requestBody: form(SnoozeThreadRequest, 'The snooze flag') }
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
const on = await formBool(c, 'snooze')
|
||||
await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
|
||||
const on = await formBool(c, 'snooze')
|
||||
await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
})
|
||||
)
|
||||
|
||||
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
|
||||
// snoozing, it's a per-member flag that pins the thread in their own inbox.
|
||||
.on(['PUT', 'POST'], '/thread/:id{[0-9]+}/favorite', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.on(
|
||||
['PUT', 'POST'],
|
||||
'/thread/:id{[0-9]+}/favorite',
|
||||
chatResultRoute(
|
||||
'Favorite or unfavorite a thread',
|
||||
[
|
||||
'Like snoozing, a per-member flag that pins the thread in the caller’s own inbox and',
|
||||
'leaves everyone else’s untouched.',
|
||||
].join(' '),
|
||||
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
|
||||
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
|
||||
return c.json(CHAT_SUCCESS)
|
||||
})
|
||||
)
|
||||
|
||||
// Add a player to a thread (`/thread/20/member/2`). Gated on the caller already being
|
||||
// in it — you can only pull someone into a conversation you're part of.
|
||||
@@ -426,23 +675,48 @@ const app = new Hono<App>()
|
||||
// the caller isn't a member (which doubles as "no such thread", keeping a thread's
|
||||
// existence private), 4 when the target is already on it, 0 on success. Idempotent —
|
||||
// re-adding an existing member changes nothing.
|
||||
.post('/thread/:id{[0-9]+}/member/:playerId{[0-9]+}', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/thread/:id{[0-9]+}/member/:playerId{[0-9]+}',
|
||||
chatResultRoute(
|
||||
'Add a player to a thread',
|
||||
[
|
||||
'Gated on the caller already being in it — you can only pull someone into a conversation',
|
||||
'you’re part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
|
||||
'does: 3 when the caller isn’t a member (which doubles as "no such thread", keeping a',
|
||||
'thread’s existence private), 4 when the target is already on it, 0 on success.',
|
||||
'Idempotent — re-adding an existing member changes nothing.',
|
||||
].join(' '),
|
||||
{
|
||||
parameters: [
|
||||
{
|
||||
name: 'playerId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The account id to add (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
successDescription: '0 success · 3 caller not a member · 4 target already on the thread',
|
||||
}
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
if (await isThreadMember(c.env.DB, chatThreadId, playerId)) {
|
||||
return c.json(CHAT_PLAYER_ALREADY_ON_THREAD)
|
||||
}
|
||||
|
||||
await addThreadMember(c.env.DB, chatThreadId, playerId)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
if (await isThreadMember(c.env.DB, chatThreadId, playerId)) {
|
||||
return c.json(CHAT_PLAYER_ALREADY_ON_THREAD)
|
||||
}
|
||||
|
||||
await addThreadMember(c.env.DB, chatThreadId, playerId)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
})
|
||||
)
|
||||
|
||||
// Move the caller's read pointer — `/thread/15/read` for the whole thread, or
|
||||
// `/thread/15/message/:messageId/read` for a specific message, which the client uses
|
||||
@@ -452,23 +726,116 @@ const app = new Hono<App>()
|
||||
// The pointer only moves forward, and never past the thread's real latest message: an
|
||||
// id the client made up (or one it read from a synthetic message) can't strand the
|
||||
// thread as permanently read.
|
||||
.on(['PUT', 'POST'], '/thread/:id{[0-9]+}/read', (c) => markRead(c))
|
||||
.on(['PUT', 'POST'], '/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read', (c) =>
|
||||
markRead(c, Number.parseInt(c.req.param('messageId'), 10))
|
||||
.on(
|
||||
['PUT', 'POST'],
|
||||
'/thread/:id{[0-9]+}/read',
|
||||
chatResultRoute(
|
||||
'Mark a whole thread read',
|
||||
[
|
||||
'Moves the caller’s read pointer to the thread’s latest message. The pointer only moves',
|
||||
'forward and never past the thread’s real latest message, so an id the client made up',
|
||||
'can’t strand the thread as permanently read. 404s for a thread the caller isn’t on.',
|
||||
].join(' '),
|
||||
{ successDescription: 'Always 0 (success)', notFound: true }
|
||||
),
|
||||
(c) => markRead(c)
|
||||
)
|
||||
.on(
|
||||
['PUT', 'POST'],
|
||||
'/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read',
|
||||
chatResultRoute(
|
||||
'Mark read up to a specific message',
|
||||
[
|
||||
'What the client sends when the view sits on a message rather than the bottom. Same',
|
||||
'forward-only, clamped pointer as the whole-thread form. 404s for a thread the caller',
|
||||
'isn’t on.',
|
||||
].join(' '),
|
||||
{
|
||||
parameters: [
|
||||
{
|
||||
name: 'messageId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The message to read up to (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
successDescription: 'Always 0 (success)',
|
||||
notFound: true,
|
||||
}
|
||||
),
|
||||
(c) => markRead(c, Number.parseInt(c.req.param('messageId'), 10))
|
||||
)
|
||||
|
||||
// A page of one thread's messages, newest first — a bare array, unlike /thread/:id.
|
||||
// `MessageCount` is the page size. 404 rather than 403 for a thread the caller isn't
|
||||
// in: whether a thread exists is itself private, so a non-member gets the same answer
|
||||
// as for a thread that's gone.
|
||||
.get('/thread/:id{[0-9]+}/message', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.get(
|
||||
'/thread/:id{[0-9]+}/message',
|
||||
describeRoute({
|
||||
tags: ['Messages'],
|
||||
summary: 'A page of one thread’s messages',
|
||||
description: [
|
||||
'Newest first — a bare ARRAY, unlike `/thread/{id}`, which serves the thread object.',
|
||||
'`MessageCount` is the page size. 404 rather than 403 for a thread the caller isn’t in:',
|
||||
'whether a thread exists is itself private, so a non-member gets the same answer as for a',
|
||||
'thread that’s gone. An empty thread is still a 200 with `[]`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_MESSAGE_COUNT)],
|
||||
responses: {
|
||||
200: json(ChatMessageDto.array(), 'The page of messages, newest first (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: NOT_A_MEMBER_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
|
||||
|
||||
return c.json(await getThreadMessages(c.env.DB, chatThreadId, { limit: messageCount(c) }))
|
||||
})
|
||||
return c.json(await getThreadMessages(c.env.DB, chatThreadId, { limit: messageCount(c) }))
|
||||
}
|
||||
)
|
||||
|
||||
// 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 chat',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Chat threads and messages for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend. A thread is a conversation — a DM pair, a named group, or a system',
|
||||
'thread — and membership is both the authorization gate and the `playerIds` the client',
|
||||
'renders. Threads, membership and messages are D1-backed; every message also fans out',
|
||||
'over the `notify` hub Durable Object as a ChatMessageReceived frame, so a conversation',
|
||||
'updates live instead of on the next poll. (The hub frame carries a STRING `Id` — the',
|
||||
'client dispatches on it and silently drops a numeric one.)',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://chat.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
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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 thread’s 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 thread’s `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
|
||||
}
|
||||
@@ -1265,3 +1265,61 @@ describe('PUT /thread/:id/favorite', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openapi', () => {
|
||||
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 schema inlines — a `$ref` here means a schema picked up a `.meta({ id })`
|
||||
// and emitted a reference the framework didn't hoist into components.schemas.
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
|
||||
// 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. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
||||
// `.on([...], …)` routes contribute every method they were registered for.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /thread/{id}/leave',
|
||||
'GET /',
|
||||
'GET /thread',
|
||||
'GET /thread/{id}',
|
||||
'GET /thread/{id}/message',
|
||||
'POST /thread',
|
||||
'POST /thread/withmembers',
|
||||
'POST /thread/{id}',
|
||||
'POST /thread/{id}/favorite',
|
||||
'POST /thread/{id}/leave',
|
||||
'POST /thread/{id}/member/{playerId}',
|
||||
'POST /thread/{id}/message',
|
||||
'POST /thread/{id}/message/{messageId}/read',
|
||||
'POST /thread/{id}/read',
|
||||
'POST /thread/{id}/rename',
|
||||
'POST /thread/{id}/snooze',
|
||||
'PUT /thread/{id}/favorite',
|
||||
'PUT /thread/{id}/message/{messageId}/read',
|
||||
'PUT /thread/{id}/read',
|
||||
'PUT /thread/{id}/rename',
|
||||
'PUT /thread/{id}/snooze',
|
||||
])
|
||||
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user