mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
b3ba6c00ff
* refactor(api)!: unify errors to AIP-193 + Page<T> pagination, enrich access log (#443) Settle the API consistency issues from #443 before SDKs ship. Breaking changes across the error envelope, list envelopes, and the generated Go client. Errors → AIP-193 google.rpc.Status (https://google.aip.dev/193): - every error body is now { error: { code, message, status, details:[ErrorInfo] } } - machine-readable, switchable key is details[0].reason (UPPER_SNAKE); status is the canonical google.rpc.Code; dynamic context lives in metadata (string→string) - built once in server/lib/http-errors.ts (buildErrorBody/ApiError/mapDomainError); inline handlers use apiError(c,status,msg,opts?); thrown errors flow through app.onError → renderError. Resolves #8 (one casing; no-storage 503 everywhere) and #9 (resource/maxBytes/conflictingName/licensing fields folded into metadata; featureGateErrorSchema removed) Pagination → Page<T> = { items, total, page, pageSize } via pageSchema + integer pageQuerySchema, applied to every list endpoint. image-hosting/images stays cursor (the one intentional exception). unreadCount moved out of the notifications list into /notifications/stats; entitlements drop the redundant orgId; team invitations use items. Access log: every 4xx/5xx carries reason + full message (set by apiError and renderError); a thrown domain error logs its mapped status (409, not 500); unhandled 500s log the full cause chain while the client gets a generic message. Frontend ApiError exposes reason/metadata/canonicalStatus; consumers updated. Go client regenerated from the new OpenAPI document. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(api): fix e2e name-conflict assertion + cover AIP-193 error branches - e2e/name-conflict.spec.ts: assert body.error.details[0].reason (AIP-193) instead of the removed top-level body.code - unit-test buildErrorBody, ApiError, and every mapDomainError branch (server/lib/http-errors.test.ts) and renderError + isHandledError (server/middleware/error-handler.test.ts) - integration-test the apiError error-branch guards the refactor touched: shares, redirect, site/invitations, objects, store/storefront, and the requirePermission middleware (authz) — restoring patch coverage above target Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(api): drop ad-hoc [spec:] breadcrumbs from new coverage tests lint:spec governs spec↔test traceability: a [spec: id] breadcrumb must map to a documented @id scenario in spec/**/*.feature. The added error-branch coverage tests are not Gherkin scenarios, so reference no spec id — use plain titles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(objects): allow the file-manager pageSize (500) on the objects list The shared pageQuerySchema caps pageSize at 100, but the file manager loads a whole folder client-side (FILES_PAGE_SIZE=500, transfer dialog 200) — the old z.string() query param was unbounded. With the cap, GET /api/objects?pageSize=500 returned 400, the file-manager list query errored and retried, and the toolbar / table never rendered (e2e: responsive @desktop + name-conflict table state). Raise just this list's ceiling to 1000 (default stays 20); other lists keep the 100 cap. Regression-tested: GET /api/objects?pageSize=500 → 200. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
import { z } from '@hono/zod-openapi'
|
|
|
|
// Every error response follows the Google API error model (AIP-193,
|
|
// https://google.aip.dev/193): a `google.rpc.Status` wrapped in `error`. One model
|
|
// for the whole API so an SDK can model "an error" once instead of a grab-bag of
|
|
// per-route top-level fields.
|
|
//
|
|
// { "error": {
|
|
// "code": 413, // HTTP status
|
|
// "message": "File exceeds the limit.", // developer-facing, English
|
|
// "status": "FAILED_PRECONDITION", // canonical google.rpc.Code name
|
|
// "details": [{
|
|
// "@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
|
// "reason": "PAYLOAD_TOO_LARGE", // the machine-readable switch key
|
|
// "domain": "zpan.dev",
|
|
// "metadata": { "maxBytes": "5242880" } // dynamic context, string→string
|
|
// }] } }
|
|
//
|
|
// Clients switch on `details[].reason` (stable, UPPER_SNAKE); `status` gives a
|
|
// transport-independent error class; loose context that used to leak as extra
|
|
// top-level fields now lives in `metadata`.
|
|
|
|
export const ERROR_DOMAIN = 'zpan.dev'
|
|
|
|
export const ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo'
|
|
|
|
// The canonical google.rpc.Code enum names we surface in `error.status`.
|
|
export const canonicalStatuses = [
|
|
'INVALID_ARGUMENT',
|
|
'FAILED_PRECONDITION',
|
|
'OUT_OF_RANGE',
|
|
'UNAUTHENTICATED',
|
|
'PERMISSION_DENIED',
|
|
'NOT_FOUND',
|
|
'ALREADY_EXISTS',
|
|
'ABORTED',
|
|
'RESOURCE_EXHAUSTED',
|
|
'CANCELLED',
|
|
'DEADLINE_EXCEEDED',
|
|
'UNIMPLEMENTED',
|
|
'UNAVAILABLE',
|
|
'DATA_LOSS',
|
|
'INTERNAL',
|
|
'UNKNOWN',
|
|
] as const
|
|
|
|
export type CanonicalStatus = (typeof canonicalStatuses)[number]
|
|
|
|
// Default HTTP status → canonical status. A specific site may override the
|
|
// canonical status (e.g. a 409 name conflict is ALREADY_EXISTS, a 422 quota
|
|
// breach is RESOURCE_EXHAUSTED) while keeping its HTTP code.
|
|
const HTTP_TO_CANONICAL: Record<number, CanonicalStatus> = {
|
|
400: 'INVALID_ARGUMENT',
|
|
401: 'UNAUTHENTICATED',
|
|
402: 'FAILED_PRECONDITION',
|
|
403: 'PERMISSION_DENIED',
|
|
404: 'NOT_FOUND',
|
|
405: 'FAILED_PRECONDITION',
|
|
409: 'ABORTED',
|
|
410: 'NOT_FOUND',
|
|
413: 'FAILED_PRECONDITION',
|
|
415: 'INVALID_ARGUMENT',
|
|
422: 'INVALID_ARGUMENT',
|
|
429: 'RESOURCE_EXHAUSTED',
|
|
500: 'INTERNAL',
|
|
501: 'UNIMPLEMENTED',
|
|
502: 'UNAVAILABLE',
|
|
503: 'UNAVAILABLE',
|
|
504: 'DEADLINE_EXCEEDED',
|
|
}
|
|
|
|
export function canonicalStatusForHttp(httpStatus: number): CanonicalStatus {
|
|
return HTTP_TO_CANONICAL[httpStatus] ?? (httpStatus >= 500 ? 'INTERNAL' : 'UNKNOWN')
|
|
}
|
|
|
|
// The machine-readable `reason` values shared across resources. One-off,
|
|
// resource-local reasons stay as string literals at their call site; these are the
|
|
// ones referenced in more than one place or worth switching on from an SDK.
|
|
export const ErrorReason = {
|
|
NAME_CONFLICT: 'NAME_CONFLICT',
|
|
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
|
|
INSUFFICIENT_CREDITS: 'INSUFFICIENT_CREDITS',
|
|
FEATURE_NOT_AVAILABLE: 'FEATURE_NOT_AVAILABLE',
|
|
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
|
UNSUPPORTED_MEDIA_TYPE: 'UNSUPPORTED_MEDIA_TYPE',
|
|
NO_STORAGE_CONFIGURED: 'NO_STORAGE_CONFIGURED',
|
|
} as const
|
|
|
|
export const errorInfoSchema = z
|
|
.object({
|
|
'@type': z.literal(ERROR_INFO_TYPE),
|
|
// Stable, UPPER_SNAKE_CASE, ≤63 chars (AIP-193 / google.rpc.ErrorInfo).
|
|
reason: z.string(),
|
|
domain: z.string(),
|
|
// Dynamic context. AIP-193 requires string values.
|
|
metadata: z.record(z.string(), z.string()).optional(),
|
|
})
|
|
.openapi('ErrorInfo')
|
|
|
|
// The canonical error body for every failing endpoint. Named once so the OpenAPI
|
|
// document — and every generated SDK — shares a single `Error` model.
|
|
export const errorResponseSchema = z
|
|
.object({
|
|
error: z.object({
|
|
code: z.number().int(),
|
|
message: z.string(),
|
|
status: z.string(),
|
|
details: z.array(errorInfoSchema).optional(),
|
|
}),
|
|
})
|
|
.openapi('Error')
|
|
|
|
export type ErrorResponse = z.infer<typeof errorResponseSchema>
|
|
export type ErrorInfo = z.infer<typeof errorInfoSchema>
|