mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
* feat(openapi): complete API coverage with truthful schemas + unified error handling
Migrate every resource router to `@hono/zod-openapi` so the global OpenAPI
document (and the SDKs generated from it) covers the whole product API, not just
~15% of it. The document now describes 25 resources with named component schemas,
operationIds, and accurate response shapes.
What changed:
- Unified error handling: a single `mapDomainError` (DownloadError, ObjectUpload-
SessionError, NameConflictError, StorageQuotaExceededError, BackgroundJobError,
WebDavPathError) wired into a global `app.onError`; handlers throw domain errors
instead of hand-rolling per-route try/catch. One shared `ErrorResponse` envelope.
- Shared http helpers (`server/http/openapi.ts`): generic `jsonContent`/`jsonBody`/
`errorResponse` so the precise schema type reaches `createRoute` — typing
`c.req.valid()` and strictly checking `c.json()` returns (no widened `z.ZodType`).
- Schemas are the truth: response schemas are named (`.openapi('X')`), wire-shaped
(ISO-string timestamps via per-resource `toXDTO` mappers where the domain type
uses `Date`), and strictly enforced against handler returns. The strict pass
surfaced and fixed several latent schema lies (e.g. transfer result shape,
download-task delete tombstone, object `purged`).
- operationId + summary on every route → clean SDK method names.
- Curated out of the public SDK (kept as plain routes): the `/r` redirect resolver,
store webhook receiver, internal telemetry endpoint, the PicGo/ShareX image
upload tool endpoint, the share download redirect, and cron-secret licensing
sync endpoints.
- Disambiguated user operationIds that collided with better-auth's admin API;
dropped `additionalProperties` schemas that oapi-codegen mis-generates.
- Regenerated the Go downloader client and realigned its hand-written wrapper to
the operationId-derived names.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(cmd): gofmt the realigned downloader client
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
5.9 KiB
TypeScript
139 lines
5.9 KiB
TypeScript
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
|
import { errorResponseSchema } from '@shared/schemas'
|
|
import { requireAuth } from '../middleware/auth'
|
|
import type { Env } from '../middleware/platform'
|
|
import { type EventsMessage, streamEvents } from '../usecases/events'
|
|
|
|
const encoder = new TextEncoder()
|
|
|
|
// Per-connection subscription, carried in the EventSource URL. Always-on domains
|
|
// (jobs, notifications) need no opt-in; page-scoped domains (download tasks) are
|
|
// polled only when the client asks for them, so an idle browser tab doesn't make
|
|
// the server scan resources no page is showing.
|
|
const eventsQuerySchema = z.object({
|
|
downloadTasks: z.string().optional(),
|
|
dtStatus: z.string().optional(),
|
|
dtCategory: z.string().optional(),
|
|
dtTag: z.string().optional(),
|
|
dtSortBy: z
|
|
.enum(['createdAt', 'source', 'category', 'tags', 'status', 'progress', 'eta'])
|
|
.optional()
|
|
.catch(undefined),
|
|
dtSortDir: z.enum(['asc', 'desc']).optional().catch(undefined),
|
|
})
|
|
|
|
// The wire/doc contract for the query string. Kept to lenient optional strings
|
|
// (no enum, no `.catch()`): the OpenAPI generator can't map a ZodCatch, and a
|
|
// malformed sort param must be silently ignored — never 400 an always-on stream.
|
|
// The strict coercion still happens in the handler via `eventsQuerySchema`.
|
|
const eventsQueryDocSchema = z.object({
|
|
downloadTasks: z.string().optional().openapi({ description: 'Set to "1" to subscribe to download-task events.' }),
|
|
dtStatus: z.string().optional(),
|
|
dtCategory: z.string().optional(),
|
|
dtTag: z.string().optional(),
|
|
dtSortBy: z
|
|
.string()
|
|
.optional()
|
|
.openapi({ description: 'One of: createdAt | source | category | tags | status | progress | eta' }),
|
|
dtSortDir: z.string().optional().openapi({ description: 'asc | desc' }),
|
|
})
|
|
|
|
// The SSE body is a stream of text/event-stream frames, not JSON, so the schema
|
|
// is just a string. OpenAPI 3.x has no native way to type the named events of a
|
|
// single stream, so they're spelled out in the route description below.
|
|
const eventStreamRoute = createRoute({
|
|
operationId: 'streamEvents',
|
|
tags: ['Events'],
|
|
method: 'get',
|
|
path: '/',
|
|
middleware: [requireAuth] as const,
|
|
summary: 'Server-sent events stream',
|
|
description: [
|
|
'A single SSE connection multiplexing several domains via named events:',
|
|
'',
|
|
'- `jobs` → `{ activeCount }` — background-job set changed (always on)',
|
|
'- `notifications` → `{ unreadCount }` — unread count changed (always on)',
|
|
'- `download-tasks` → `{ items, total, page, pageSize }` — download tasks changed (opt-in via `?downloadTasks=1`)',
|
|
'- `heartbeat` → `{ at }` — keep-alive emitted when nothing changed for a while',
|
|
'- `error` → `{ message }` — a domain query failed this tick',
|
|
].join('\n'),
|
|
request: { query: eventsQueryDocSchema },
|
|
responses: {
|
|
200: {
|
|
content: { 'text/event-stream': { schema: z.string() } },
|
|
description: 'Open SSE stream of domain-change events',
|
|
},
|
|
401: { content: { 'application/json': { schema: errorResponseSchema } }, description: 'Unauthorized' },
|
|
},
|
|
})
|
|
|
|
// One SSE stream multiplexing several domains via named events:
|
|
// event: jobs → { activeCount } background-job set changed (always on)
|
|
// event: notifications → { unreadCount } unread count changed (always on)
|
|
// event: download-tasks → { items, total, page, pageSize } download tasks changed (opt-in via ?downloadTasks=1)
|
|
// event: heartbeat → { at } no change for HEARTBEAT_INTERVAL_MS
|
|
// event: error → { message } a domain query failed this tick
|
|
//
|
|
// The browser opens a single EventSource and dispatches by event name; each
|
|
// handler refreshes the matching React Query cache. See src/hooks/useServerEvents.ts.
|
|
//
|
|
// This handler owns only the wire: it builds the ReadableStream, encodes each
|
|
// domain event the usecase emits as an SSE frame, and returns the Response. All
|
|
// polling / fingerprint / change-detection lives in streamEvents (usecases/events.ts).
|
|
export const events = new OpenAPIHono<Env>().openapi(eventStreamRoute, (c) => {
|
|
const deps = c.get('deps')
|
|
const query = eventsQuerySchema.parse(c.req.query())
|
|
|
|
// One controller, aborted from BOTH teardown paths. In Workers the request
|
|
// signal and ReadableStream.cancel() are independent: passing c.req.raw.signal
|
|
// straight to the usecase would leak the poll loop when only the body consumer
|
|
// cancels. So we own the controller and bridge both into it.
|
|
const abort = new AbortController()
|
|
c.req.raw.signal.addEventListener('abort', () => abort.abort())
|
|
|
|
const params = {
|
|
platform: c.get('platform'),
|
|
orgId: c.get('orgId'),
|
|
userId: c.get('userId'),
|
|
wantsDownloadTasks: query.downloadTasks === '1',
|
|
dtStatus: query.dtStatus,
|
|
dtCategory: query.dtCategory,
|
|
dtTag: query.dtTag,
|
|
dtSortBy: query.dtSortBy,
|
|
dtSortDir: query.dtSortDir,
|
|
}
|
|
|
|
// One teardown signal fed from both independent Workers paths (request abort
|
|
// AND body-consumer cancel). streamClosed guards the controller: a consumer
|
|
// cancel() already closes the controller before it fires the abort listener,
|
|
// so closing again would throw ERR_INVALID_STATE.
|
|
let streamClosed = false
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const emit = (message: EventsMessage) => {
|
|
controller.enqueue(encoder.encode(`event: ${message.event}\ndata: ${JSON.stringify(message.data)}\n\n`))
|
|
}
|
|
abort.signal.addEventListener('abort', () => {
|
|
if (streamClosed) return
|
|
streamClosed = true
|
|
controller.close()
|
|
})
|
|
void streamEvents(deps, params, abort.signal, emit)
|
|
},
|
|
cancel() {
|
|
streamClosed = true
|
|
abort.abort()
|
|
},
|
|
})
|
|
|
|
return c.newResponse(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
},
|
|
})
|
|
})
|
|
|
|
export default events
|