Skip to content

Commit 8d190dc

Browse files
dengzhaofunclaude
andauthored
feat(api): 全业务 API 统一 {code, data, message, requestId} envelope (#49)
* WIP: envelope refactor before merging origin/main * feat(api): unify every business endpoint on a `{code, data, message, requestId}` envelope Why - SaaS SDKs (TS now, C#/Go/Python planned) need a stable response contract — across 30 modules / 459 operations we had no uniform success shape, only a loose `{error, code?, requestId?}` on failure. Each codegen target would have to special-case every route's return type. - Admin / client wrappers have no single unwrap point, so error toasts and loading / success branching are ad-hoc everywhere. - Validation errors escaped the envelope entirely (`@hono/zod-openapi` default `{success:false, error}`), so the frontend had to handle two error shapes. What - `apps/server/src/lib/response.ts` — `ok()` / `fail()` helpers that read `requestId` from the `requestContext` AsyncLocalStorage (no plumbing through every handler); `envelopeOf<T>(schema)` factory to wrap any zod response schema in the envelope; `ErrorEnvelopeSchema` / `NullDataEnvelopeSchema` + `commonErrorResponses` map for every router's `responses` block. - `apps/server/src/lib/openapi.ts` — `createAdminRouter` / `createClientRouter` / `createPublicRouter` now bundle the `defaultHook` (validation failure → envelope with `code: "validation_error"`) and an `onError` that maps every `ModuleError` subclass to the envelope using the subclass's `code` + `httpStatus`. No module writes its own `router.onError` anymore. - All 30 business modules (admin + client routes) reapplied: every 2xx success schema wrapped in `envelopeOf(...)`, every handler return wrapped in `ok(...)`, every `c.body(null, 204)` turned into `c.json(ok(null), 200)` with `NullDataEnvelopeSchema`. DELETE / ack endpoints no longer return 204 — everything is 200 + envelope so the SDK/frontend unwrap doesn't need a status branch. - Deleted the per-module `ErrorResponseSchema` (29 of 30 modules via agent, level via merge resolution) — everything now references the shared `ApiErrorEnvelope` component. - `apps/server/src/index.ts` global `app.onError` → `fail(INTERNAL_ERROR_CODE, err.message)` + HTTP 500; new `app.notFound` returns `{code:"not_found", message:"Not found: <method> <path>"}` + HTTP 404 so unknown paths stop leaking plain-text "404 Not Found". - `apps/admin/src/lib/api-client.ts` unwraps `body.data` transparently on success and synthesizes `ApiError.body.error` from the envelope's `message` for backward compatibility (80+ call sites reading `err.body.error` stay untouched). Added `err.code` / `err.requestId` getters for new code paths. - Drive-by fix in `apps/server/src/db.ts`: added `import type { Pool as PgPool } from "pg"` — the local-Postgres branch referenced `pg.Pool` as a namespace at the top level but the runtime `pg` import was function-scoped, so `tsc --noEmit` failed on main before this PR. - `apps/server/CLAUDE.md` — retired the "Error handling — throw, map in router onError" section in favor of a "Response envelope" section that walks through `createAdminRouter` / `createAdminRoute` / `envelopeOf` / `ok()` / `NullDataEnvelopeSchema` as the canonical route recipe. - SDK regenerated: `packages/sdk-core/specs/openapi*.json` (459 ops, 403 schemas with `ApiErrorEnvelope` + `ApiNullEnvelope` replacing the 30 old per-module `XxxErrorResponse` types); `packages/sdk-{admin,client}-ts` generated clients are rewired accordingly. Not in scope (follow-ups) - `serializeXxx` functions in every module are 70% redundant boilerplate — left untouched deliberately; a separate PR will replace them with either zod `.parse()` or direct Drizzle-row pass-through. - Auth middlewares (`require-admin-or-api-key`, `require-client-credential`, etc.) still return `{error, requestId}` on 401/403. Routing them through `throw new ModuleError("unauthorized", 401, ...)` so the factory `onError` handles them will unify the last 401/403 gap. Validation - `pnpm --filter=server check-types` + `lint --max-warnings 0` — green - `pnpm --filter=server test src/modules/check-in` — 28/28 (admin + client + service layers cover envelope contract on create, delete, error codes) - `pnpm --filter=@apollokit/admin check-types` + `@apollokit/client check-types` — green after SDK regen - Live smoke test in browser against admin dev: create check-in config → 201 envelope; delete → 200 + `data:null`; create banner group → 201 envelope + detail page loads; friend `GET /settings` nullable returns 200 + `data:null` and the UI shows the empty state cleanly; curl to an unknown path returns the envelope 404. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f4969fa commit 8d190dc

105 files changed

Lines changed: 192768 additions & 14526 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/admin/src/lib/api-client.ts

Lines changed: 126 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,114 @@
1+
/**
2+
* Thin HTTP client for the server's `/api/*` business routes.
3+
*
4+
* Every business endpoint returns the standard envelope:
5+
* { code: string, data: T | null, message: string, requestId: string }
6+
*
7+
* This wrapper unwraps `.data` on success, so hooks and routes can
8+
* stay on their original typings — e.g. `useQuery` still receives the
9+
* resource object / `{ items }` list, not the envelope.
10+
*
11+
* Errors are normalized into `ApiError` whose `body.error` mirrors the
12+
* envelope's `message` for backward compatibility (dozens of existing
13+
* toast call sites read `err.body.error`). New code can prefer
14+
* `err.message`, `err.code`, or `err.requestId` directly.
15+
*
16+
* NOTE: Better Auth (`/api/auth/*`) uses its own client in
17+
* `lib/auth-client.ts` and does NOT go through this wrapper — so the
18+
* envelope assumption is safe here.
19+
*/
20+
121
const BASE_URL =
222
import.meta.env.VITE_AUTH_SERVER_URL ?? "http://localhost:8787"
323

24+
type ApiErrorBody = {
25+
/** Backward-compat alias for `message`. */
26+
error: string
27+
code: string
28+
message: string
29+
requestId: string
30+
}
31+
432
export class ApiError extends Error {
533
constructor(
634
public status: number,
7-
public body: { error: string; code?: string },
35+
public body: ApiErrorBody,
836
) {
9-
super(body.error)
37+
super(body.message || body.error)
1038
this.name = "ApiError"
1139
}
40+
41+
get code(): string {
42+
return this.body.code
43+
}
44+
45+
get requestId(): string {
46+
return this.body.requestId
47+
}
48+
}
49+
50+
type SuccessEnvelope<T> = {
51+
code: "ok"
52+
data: T | null
53+
message: string
54+
requestId: string
55+
}
56+
57+
type ErrorEnvelope = {
58+
code: string
59+
data: null
60+
message: string
61+
requestId: string
62+
}
63+
64+
type AnyEnvelope<T> = SuccessEnvelope<T> | ErrorEnvelope
65+
66+
function isEnvelope(value: unknown): value is AnyEnvelope<unknown> {
67+
return (
68+
typeof value === "object" &&
69+
value !== null &&
70+
"code" in value &&
71+
typeof (value as { code: unknown }).code === "string" &&
72+
"requestId" in value
73+
)
74+
}
75+
76+
function toErrorBody(status: number, parsed: unknown): ApiErrorBody {
77+
if (isEnvelope(parsed)) {
78+
return {
79+
error: parsed.message,
80+
code: parsed.code,
81+
message: parsed.message,
82+
requestId: parsed.requestId,
83+
}
84+
}
85+
// Fallback for non-envelope error bodies (e.g. unexpected 5xx from a
86+
// proxy, or legacy endpoint that slipped through). Preserve whatever
87+
// text/JSON we got so the toast still shows something useful.
88+
const fallbackMessage =
89+
typeof parsed === "string"
90+
? parsed
91+
: parsed && typeof parsed === "object" && "error" in parsed &&
92+
typeof (parsed as { error: unknown }).error === "string"
93+
? (parsed as { error: string }).error
94+
: `Request failed with status ${status}`
95+
return {
96+
error: fallbackMessage,
97+
code: "http_error",
98+
message: fallbackMessage,
99+
requestId: "",
100+
}
101+
}
102+
103+
async function parseBody(res: Response): Promise<unknown> {
104+
if (res.status === 204) return undefined
105+
const text = await res.text()
106+
if (!text) return undefined
107+
try {
108+
return JSON.parse(text)
109+
} catch {
110+
return text
111+
}
12112
}
13113

14114
async function request<T>(
@@ -24,15 +124,22 @@ async function request<T>(
24124
},
25125
})
26126

27-
if (res.status === 204) return undefined as T
28-
29-
const body = await res.json()
127+
const parsed = await parseBody(res)
30128

31129
if (!res.ok) {
32-
throw new ApiError(res.status, body)
130+
throw new ApiError(res.status, toErrorBody(res.status, parsed))
33131
}
34132

35-
return body as T
133+
if (parsed === undefined) {
134+
return undefined as T
135+
}
136+
137+
// Unwrap envelope; non-envelope responses (shouldn't happen after
138+
// the server-side migration, but just in case) fall through as-is.
139+
if (isEnvelope(parsed)) {
140+
return parsed.data as T
141+
}
142+
return parsed as T
36143
}
37144

38145
/**
@@ -55,12 +162,20 @@ async function uploadFormData<T>(
55162
credentials: "include",
56163
body: form,
57164
})
58-
if (res.status === 204) return undefined as T
59-
const body = await res.json()
165+
166+
const parsed = await parseBody(res)
167+
60168
if (!res.ok) {
61-
throw new ApiError(res.status, body)
169+
throw new ApiError(res.status, toErrorBody(res.status, parsed))
170+
}
171+
172+
if (parsed === undefined) {
173+
return undefined as T
174+
}
175+
if (isEnvelope(parsed)) {
176+
return parsed.data as T
62177
}
63-
return body as T
178+
return parsed as T
64179
}
65180

66181
export const api = {

apps/server/CLAUDE.md

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -168,26 +168,123 @@ Do **not** mount it globally on `app` in `src/index.ts`. Future public
168168
routes (API-key / JWT auth for tenant frontends) must be free of
169169
`requireAuth`, and a global mount would quietly break them.
170170

171-
## Error handling — throw, map in router `onError`
171+
## Response envelope — every business endpoint returns `{code, data, message, requestId}`
172+
173+
All business routes (everything under `/api/*` EXCEPT the Better Auth
174+
mounts `/api/auth/*` and `/api/client/auth/*`, which are third-party
175+
owned) return the standard envelope from
176+
`src/lib/response.ts`:
177+
178+
```jsonc
179+
// success
180+
{ "code": "ok", "data": <payload>, "message": "", "requestId": "..." }
181+
// business error (HTTP 4xx)
182+
{ "code": "check_in.config_not_found", "data": null, "message": "...", "requestId": "..." }
183+
// validation error (HTTP 400)
184+
{ "code": "validation_error", "data": null, "message": "...", "requestId": "..." }
185+
// unhandled (HTTP 500)
186+
{ "code": "internal_error", "data": null, "message": "...", "requestId": "..." }
187+
```
172188

173-
Routes throw `ModuleError` subclasses from handlers. Each router
174-
declares an `onError` that maps them:
189+
HTTP status codes follow REST: success 2xx, business/validation 4xx,
190+
unhandled 5xx. Deletes and other "no payload" endpoints return HTTP
191+
200 with `data: null` — NEVER 204, so the SDK/frontend unwrap logic
192+
doesn't have to branch on status.
175193

176-
```ts
177-
checkInRouter.onError((err, c) => {
178-
if (err instanceof ModuleError) {
179-
return c.json(
180-
{ error: err.message, code: err.code, requestId: c.get("requestId") },
181-
err.httpStatus as ContentfulStatusCode,
182-
);
183-
}
184-
throw err; // global app.onError → 500
185-
});
186-
```
194+
### How to write a route
195+
196+
1. Build the router with a factory from `lib/openapi.ts`, NOT
197+
`new OpenAPIHono<HonoEnv>()`:
198+
199+
```ts
200+
import { createAdminRouter } from "../../lib/openapi";
201+
export const checkInRouter = createAdminRouter();
202+
```
203+
204+
Three factories exist — pick the one that matches the route's auth:
205+
- `createAdminRouter()` — admin dashboard (session or `ak_`).
206+
- `createClientRouter()` — end-user `cpk_` + HMAC.
207+
- `createPublicRouter()` — unauthenticated (health, etc).
208+
209+
Each factory wires:
210+
- `defaultHook` — Zod validation failures become the envelope with
211+
`code: "validation_error"`, HTTP 400.
212+
- `onError``ModuleError` instances become the envelope with the
213+
subclass's `code` and `httpStatus`. Unknown errors rethrow to the
214+
global `app.onError` which returns a 500 envelope.
215+
216+
**Do not** write a `router.onError(...)` block in a module — the
217+
factory owns that. If you need module-specific error handling,
218+
extend `ModuleError` with a new subclass.
219+
220+
2. Declare each route with the matching `createXxxRoute` wrapper
221+
(adds `security` + `operationId`) and wrap every success response
222+
schema in `envelopeOf(...)`:
223+
224+
```ts
225+
import { createAdminRoute } from "../../lib/openapi";
226+
import { envelopeOf, commonErrorResponses, NullDataEnvelopeSchema } from "../../lib/response";
227+
228+
createAdminRoute({
229+
method: "get",
230+
path: "/configs/{id}",
231+
responses: {
232+
200: {
233+
description: "OK",
234+
content: { "application/json": { schema: envelopeOf(CheckInConfigResponseSchema) } },
235+
},
236+
...commonErrorResponses, // 400 / 401 / 403 / 404 / 409 / 500
237+
},
238+
});
239+
240+
// For delete / ack — 200 + null data (do NOT use 204)
241+
createAdminRoute({
242+
method: "delete",
243+
path: "/configs/{id}",
244+
responses: {
245+
200: {
246+
description: "Deleted",
247+
content: { "application/json": { schema: NullDataEnvelopeSchema } },
248+
},
249+
...commonErrorResponses,
250+
},
251+
});
252+
```
253+
254+
This keeps the emitted OpenAPI spec honest about the wire format,
255+
so the generated SDK types in `packages/sdk-*-ts` are accurate.
256+
257+
3. Wrap every handler return in `ok(...)`:
258+
259+
```ts
260+
import { ok } from "../../lib/response";
261+
262+
return c.json(ok(serializeConfig(row)), 201);
263+
return c.json(ok({ items: rows.map(serializeConfig) }), 200);
264+
return c.json(ok(null), 200); // delete / ack
265+
```
266+
267+
`ok()` reads `requestId` from the `requestContext` AsyncLocalStorage —
268+
the handler doesn't need to pass anything.
269+
270+
4. **Do not** define a per-module `ErrorResponseSchema` in
271+
`validators.ts`. The shared `ErrorEnvelopeSchema` in
272+
`lib/response.ts` is what every 4xx/5xx response points at.
273+
274+
### Why not wrap via middleware?
275+
276+
A response-rewriting middleware would be smaller code, but the emitted
277+
OpenAPI spec would still declare the unwrapped payload as the response
278+
body — which would make every generated SDK type wrong. The explicit
279+
`envelopeOf(schema)` at the declaration site is the price we pay for
280+
accurate SDK contracts.
281+
282+
### Don't try to return `c.json(..., err.httpStatus)` inline per handler
187283

188-
Don't try to return `c.json(..., err.httpStatus)` inline per handler:
189284
`@hono/zod-openapi` requires every status literal to match a specific
190-
declared response, and a runtime-typed status won't narrow.
285+
declared response, and a runtime-typed status won't narrow. Throw
286+
`ModuleError` subclasses and let the router factory's `onError`
287+
translate them.
191288

192289
## Event history belongs to the unified behavior log (not here)
193290

apps/server/src/db.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { env } from "cloudflare:workers";
22
import { neon } from "@neondatabase/serverless";
33
import { upstashCache } from "drizzle-orm/cache/upstash";
44
import { drizzle as drizzleNeon, type NeonHttpDatabase } from "drizzle-orm/neon-http";
5+
import type { Pool as PgPool } from "pg";
56

67
import * as schema from "./schema";
78

@@ -87,7 +88,7 @@ export const db: NeonHttpDatabase<typeof schema> = isNeon
8788
async end() {},
8889
};
8990
const drz = drizzle({
90-
client: poolShim as unknown as pg.Pool,
91+
client: poolShim as unknown as PgPool,
9192
schema,
9293
cache,
9394
});

apps/server/src/index.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { endUserAuth, EU_ORG_ID_HEADER } from "./end-user-auth";
1111
import type { HonoEnv } from "./env";
1212
import { registerSecuritySchemes, validationDefaultHook } from "./lib/openapi";
1313
import { requestContext } from "./lib/request-context";
14+
import { INTERNAL_ERROR_CODE, NOT_FOUND_CODE, fail } from "./lib/response";
1415
import { requireClientCredential } from "./middleware/require-client-credential";
1516
import { requestLog } from "./middleware/request-log";
1617
import { session } from "./middleware/session";
@@ -127,15 +128,26 @@ app.use(
127128
}),
128129
);
129130

130-
// Global error handler
131+
// Global error handler — returns the standard envelope. Module-level
132+
// routers handle `ModuleError` in their own `onError` (installed by
133+
// `createAdminRouter` / `createClientRouter` in `lib/openapi.ts`);
134+
// anything that reaches here is unexpected.
131135
app.onError((err, c) => {
132136
console.error(err);
133-
return c.json(
134-
{ error: err.message, requestId: c.get("requestId") },
135-
500,
136-
);
137+
return c.json(fail(INTERNAL_ERROR_CODE, err.message), 500);
137138
});
138139

140+
// Global 404 — fires when no route matched at all (Hono's default is
141+
// `404 Not Found` plain text). Business module routers already cover
142+
// their own sub-paths; Better Auth's `/api/auth/*` and
143+
// `/api/client/auth/*` wildcards claim those prefixes. Anything that
144+
// still lands here is an unknown URL, so we return the standard
145+
// envelope so SDKs / frontend wrappers don't have to branch on
146+
// content-type.
147+
app.notFound((c) =>
148+
c.json(fail(NOT_FOUND_CODE, `Not found: ${c.req.method} ${c.req.path}`), 404),
149+
);
150+
139151
// Better Auth — handle all /api/auth/* routes (uses module-level auth instance)
140152
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
141153

@@ -265,7 +277,7 @@ app.doc31("/openapi.json", {
265277
"apollokit is a multi-tenant game-SaaS backend. Routes are split into\n\n" +
266278
"- **Admin** (`/api/<module>/...`): used by SaaS operators from the admin dashboard. Authenticate with a Better Auth session cookie or an admin API key (`Authorization: Bearer ak_…`).\n" +
267279
"- **Client** (`/api/client/<module>/...`): consumed by tenant frontends on behalf of end users. Authenticate with a client public key (`X-Client-Public-Key: cpk_…`) plus HMAC headers (`X-Client-Signature`, `X-Client-Timestamp`, `X-Client-Nonce`).\n\n" +
268-
"Validation errors return HTTP 400 with `{ error, code: \"VALIDATION_ERROR\", issues, requestId }`. Domain errors return their declared status with `{ error, code, requestId }`.",
280+
"Every business endpoint returns the standard envelope `{ code, data, message, requestId }`. Success uses `code: \"ok\"` and the payload in `data`. Validation errors use HTTP 400 and `code: \"validation_error\"`. Domain errors use the module-specific `code` (e.g. `check_in.config_not_found`) at their declared HTTP status. Better Auth routes (`/api/auth/*`, `/api/client/auth/*`) keep the third-party library's native format.",
269281
},
270282
servers: [{ url: "http://localhost:8787", description: "Dev" }],
271283
});

0 commit comments

Comments
 (0)