@gusnips/server 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -1
- package/dist/hono/index.d.ts +6 -0
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +6 -0
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/request-logger.d.ts +17 -5
- package/dist/hono/request-logger.d.ts.map +1 -1
- package/dist/hono/request-logger.js +14 -2
- package/dist/hono/request-logger.js.map +1 -1
- package/dist/hono/responses.d.ts +43 -0
- package/dist/hono/responses.d.ts.map +1 -0
- package/dist/hono/responses.js +20 -0
- package/dist/hono/responses.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/logger/serialize.d.ts +15 -3
- package/dist/logger/serialize.d.ts.map +1 -1
- package/dist/logger/serialize.js +24 -4
- package/dist/logger/serialize.js.map +1 -1
- package/dist/responses.d.ts +58 -1
- package/dist/responses.d.ts.map +1 -1
- package/dist/responses.js +42 -9
- package/dist/responses.js.map +1 -1
- package/package.json +1 -1
- package/src/hono/index.ts +6 -0
- package/src/hono/request-logger.test.ts +75 -3
- package/src/hono/request-logger.ts +33 -5
- package/src/hono/responses.test.ts +50 -0
- package/src/hono/responses.ts +41 -0
- package/src/index.ts +14 -2
- package/src/logger/serialize.test.ts +47 -0
- package/src/logger/serialize.ts +23 -4
- package/src/responses.test.ts +38 -0
- package/src/responses.ts +64 -13
package/dist/responses.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"responses.js","sourceRoot":"","sources":["../src/responses.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC
|
|
1
|
+
{"version":3,"file":"responses.js","sourceRoot":"","sources":["../src/responses.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/hono/index.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* app.onError(errorHandler({ errorResponse, logger }));
|
|
7
7
|
* app.notFound(notFoundHandler(errorResponse(errors.notFound("Route"))));
|
|
8
8
|
*
|
|
9
|
+
* `ok`, `created`, `paginated` and `noContent` are the success half: they take the `Context` and
|
|
10
|
+
* put the envelope on the wire, so no route has to unwrap the `{ status, body }` answer that the
|
|
11
|
+
* framework-free builders return. Three adopters wrote them by hand and one got that unwrap wrong
|
|
12
|
+
* in production — see `responses.ts` beside this file.
|
|
13
|
+
*
|
|
9
14
|
* and, in a test of the real app, `assertEveryRouteGuarded(app, { isPublic })`, with the rule the
|
|
10
15
|
* app itself uses for what anyone may call.
|
|
11
16
|
*/
|
|
@@ -13,5 +18,6 @@ export { errorBoundary, errorHandler, notFoundHandler } from "./errors.ts";
|
|
|
13
18
|
export type { ErrorHandlerOptions } from "./errors.ts";
|
|
14
19
|
export { assertEveryRouteGuarded, guard, underAny } from "./guards.ts";
|
|
15
20
|
export type { GuardCheckOptions } from "./guards.ts";
|
|
21
|
+
export { created, noContent, ok, paginated } from "./responses.ts";
|
|
16
22
|
export { requestLogger } from "./request-logger.ts";
|
|
17
23
|
export type { RequestLoggerOptions, RequestVariables } from "./request-logger.ts";
|
|
@@ -7,20 +7,92 @@ import {
|
|
|
7
7
|
type RequestVariables,
|
|
8
8
|
} from "./request-logger.ts";
|
|
9
9
|
|
|
10
|
-
function setup
|
|
10
|
+
function setup<E extends { Variables: RequestVariables } = { Variables: RequestVariables }>(
|
|
11
|
+
options: Omit<RequestLoggerOptions<E>, "logger"> = {},
|
|
12
|
+
) {
|
|
11
13
|
const lines: Array<Record<string, unknown>> = [];
|
|
12
14
|
const logger = createLogger({
|
|
13
15
|
level: "debug",
|
|
14
16
|
write: (line) => lines.push(JSON.parse(line) as Record<string, unknown>),
|
|
15
17
|
});
|
|
16
|
-
const app = new Hono<
|
|
17
|
-
app.use(requestLogger({ logger, ...options }));
|
|
18
|
+
const app = new Hono<E>();
|
|
19
|
+
app.use(requestLogger<E>({ logger, ...options }));
|
|
18
20
|
return { app, lines };
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
22
24
|
|
|
25
|
+
type AppEnv = {
|
|
26
|
+
Variables: RequestVariables<"DENIED"> & { actorId: string };
|
|
27
|
+
};
|
|
28
|
+
|
|
23
29
|
describe("the request line", () => {
|
|
30
|
+
it("adds typed adopter fields after the response exists", async () => {
|
|
31
|
+
const { app, lines } = setup<AppEnv>({
|
|
32
|
+
fields: (c) => ({ actorId: c.get("actorId"), responseStatus: c.res.status }),
|
|
33
|
+
});
|
|
34
|
+
app.post("/items", (c) => {
|
|
35
|
+
c.set("actorId", "user_1");
|
|
36
|
+
return c.text("created", 201);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await app.request("/items", { method: "POST" });
|
|
40
|
+
|
|
41
|
+
expect(lines[0]).toMatchObject({ actorId: "user_1", responseStatus: 201 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps every canonical request field when adopter fields use the same names", async () => {
|
|
45
|
+
const { app, lines } = setup<AppEnv>({
|
|
46
|
+
fields: () => ({
|
|
47
|
+
actorId: "user_1",
|
|
48
|
+
requestId: "wrong",
|
|
49
|
+
method: "DELETE",
|
|
50
|
+
route: "/wrong",
|
|
51
|
+
status: 599,
|
|
52
|
+
ms: -1,
|
|
53
|
+
errorCode: "WRONG",
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
app.post("/items/:id", (c) => {
|
|
57
|
+
c.set("errorCode", "DENIED");
|
|
58
|
+
return c.text("no", 403);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await app.request("/items/secret", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "X-Request-ID": "trace_1" },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(lines[0]).toMatchObject({
|
|
67
|
+
actorId: "user_1",
|
|
68
|
+
requestId: "trace_1",
|
|
69
|
+
method: "POST",
|
|
70
|
+
route: "/items/:id",
|
|
71
|
+
status: 403,
|
|
72
|
+
errorCode: "DENIED",
|
|
73
|
+
});
|
|
74
|
+
expect(lines[0]!.ms).not.toBe(-1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not let a broken adopter field hook lose the response or its request line", async () => {
|
|
78
|
+
const { app, lines } = setup<AppEnv>({
|
|
79
|
+
fields: () => {
|
|
80
|
+
throw new Error("field hook broke");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
app.get("/items", (c) => c.text("ok"));
|
|
84
|
+
|
|
85
|
+
const response = await app.request("/items");
|
|
86
|
+
|
|
87
|
+
expect(response.status).toBe(200);
|
|
88
|
+
expect(lines[0]).toMatchObject({
|
|
89
|
+
requestFieldsFailed: true,
|
|
90
|
+
method: "GET",
|
|
91
|
+
route: "/items",
|
|
92
|
+
status: 200,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
24
96
|
it("names the route template, never the path", async () => {
|
|
25
97
|
// A path carries whatever the caller put in it. In one backend that was a customer's national
|
|
26
98
|
// id number, and the request line carried it into the log and on into an analytics event.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MiddlewareHandler } from "hono";
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import { routePath } from "hono/route";
|
|
3
3
|
import type { Logger } from "../logger/index.ts";
|
|
4
4
|
|
|
@@ -9,8 +9,20 @@ export interface RequestVariables<Code extends string = string> {
|
|
|
9
9
|
errorCode: Code | null;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
type RequestLoggerEnv = { Variables: RequestVariables };
|
|
13
|
+
|
|
14
|
+
export interface RequestLoggerOptions<E extends RequestLoggerEnv = RequestLoggerEnv> {
|
|
13
15
|
logger: Logger;
|
|
16
|
+
/**
|
|
17
|
+
* Sanitized product fields to add to the request line. Runs after the response exists, so it can
|
|
18
|
+
* read downstream variables and `c.res`. Keep caller-controlled values bounded; never return a
|
|
19
|
+
* raw path, query, header set, body or authentication object.
|
|
20
|
+
*
|
|
21
|
+
* Return plain data, not live objects: a throw from this hook is caught, but a value whose own
|
|
22
|
+
* `toJSON` throws is caught by the logger instead, which costs the whole line rather than the
|
|
23
|
+
* field.
|
|
24
|
+
*/
|
|
25
|
+
fields?: (c: Context<E>) => Record<string, unknown>;
|
|
14
26
|
/**
|
|
15
27
|
* Paths answered but never logged, each with everything under it: `/health` covers
|
|
16
28
|
* `/health/db` and not `/healthz`. Defaults to `["/health"]`. A throw is logged anyway.
|
|
@@ -25,6 +37,20 @@ export interface RequestLoggerOptions {
|
|
|
25
37
|
*/
|
|
26
38
|
const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
27
39
|
|
|
40
|
+
function collectFields<E extends RequestLoggerEnv>(
|
|
41
|
+
fields: RequestLoggerOptions<E>["fields"],
|
|
42
|
+
c: Context<E>,
|
|
43
|
+
): Record<string, unknown> {
|
|
44
|
+
if (fields === undefined) return {};
|
|
45
|
+
try {
|
|
46
|
+
// Materialize here too: a throwing getter is just as capable of losing the request line as a
|
|
47
|
+
// throwing callback. Logging metadata must never change the response it describes.
|
|
48
|
+
return { ...fields(c) };
|
|
49
|
+
} catch {
|
|
50
|
+
return { requestFieldsFailed: true };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
/**
|
|
29
55
|
* One line per request, and the request id.
|
|
30
56
|
*
|
|
@@ -36,10 +62,11 @@ const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
|
36
62
|
* answer including `onError`'s and `notFound`'s. Cross-origin, list that header in your CORS
|
|
37
63
|
* `exposeHeaders` or the browser hides it from the page.
|
|
38
64
|
*/
|
|
39
|
-
export function requestLogger({
|
|
65
|
+
export function requestLogger<E extends RequestLoggerEnv = RequestLoggerEnv>({
|
|
40
66
|
logger,
|
|
67
|
+
fields,
|
|
41
68
|
skipPaths = ["/health"],
|
|
42
|
-
}: RequestLoggerOptions): MiddlewareHandler<
|
|
69
|
+
}: RequestLoggerOptions<E>): MiddlewareHandler<E> {
|
|
43
70
|
return async (c, next) => {
|
|
44
71
|
const supplied = c.req.header("X-Request-ID");
|
|
45
72
|
const requestId = supplied && REQUEST_ID.test(supplied) ? supplied : crypto.randomUUID();
|
|
@@ -72,6 +99,7 @@ export function requestLogger({
|
|
|
72
99
|
// returns a Response it built itself.
|
|
73
100
|
c.header("X-Request-ID", requestId);
|
|
74
101
|
const skipped = skipPaths.some((skip) => path === skip || path.startsWith(`${skip}/`));
|
|
75
|
-
if (method !== "OPTIONS" && !skipped)
|
|
102
|
+
if (method !== "OPTIONS" && !skipped)
|
|
103
|
+
logger.info("request", { ...collectFields(fields, c), ...line(c.res.status) });
|
|
76
104
|
};
|
|
77
105
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { created, noContent, ok, paginated } from "./responses.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every assertion here reads the BYTES off a real request, never the return value of a builder.
|
|
7
|
+
* That is the point: the outage this file exists to prevent was invisible to any test that calls
|
|
8
|
+
* the module, because the wrong value was still a perfectly good JSON value.
|
|
9
|
+
*/
|
|
10
|
+
const app = new Hono()
|
|
11
|
+
.get("/ok", (c) => ok(c, { id: "a" }))
|
|
12
|
+
.get("/accepted", (c) => ok(c, { id: "a" }, 202))
|
|
13
|
+
.post("/created", (c) => created(c, { id: "a" }))
|
|
14
|
+
.get("/page", (c) => paginated(c, [1, 2], { total: 9, limit: 2, offset: 0 }))
|
|
15
|
+
.get("/last-page", (c) => paginated(c, [9], { total: 9, limit: 2, offset: 8 }))
|
|
16
|
+
.delete("/gone", (c) => noContent(c));
|
|
17
|
+
|
|
18
|
+
describe("the Hono success adapters", () => {
|
|
19
|
+
it("answers the envelope itself, never the { status, body } wrapper around it", async () => {
|
|
20
|
+
const res = await app.request("/ok");
|
|
21
|
+
expect(res.status).toBe(200);
|
|
22
|
+
expect(await res.json()).toEqual({ data: { id: "a" } });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("keeps the status the route asked for", async () => {
|
|
26
|
+
expect((await app.request("/accepted")).status).toBe(202);
|
|
27
|
+
expect((await app.request("/created", { method: "POST" })).status).toBe(201);
|
|
28
|
+
expect(await (await app.request("/created", { method: "POST" })).json()).toEqual({
|
|
29
|
+
data: { id: "a" },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("computes hasMore from the rows returned, not from the limit", async () => {
|
|
34
|
+
expect(await (await app.request("/page")).json()).toEqual({
|
|
35
|
+
data: [1, 2],
|
|
36
|
+
meta: { total: 9, limit: 2, offset: 0, hasMore: true },
|
|
37
|
+
});
|
|
38
|
+
expect(await (await app.request("/last-page")).json()).toEqual({
|
|
39
|
+
data: [9],
|
|
40
|
+
meta: { total: 9, limit: 2, offset: 8, hasMore: false },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("sends a 204 with no body and no content-type", async () => {
|
|
45
|
+
const res = await app.request("/gone", { method: "DELETE" });
|
|
46
|
+
expect(res.status).toBe(204);
|
|
47
|
+
expect(res.headers.get("content-type")).toBeNull();
|
|
48
|
+
expect(await res.text()).toBe("");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The success half of the Hono edge: four adapters that put the envelope on the wire.
|
|
3
|
+
*
|
|
4
|
+
* They exist because the builders in `../responses.ts` return an ANSWER — `{ status, body }` —
|
|
5
|
+
* and every Hono adopter therefore has to unwrap one before `c.json` sees it. Three of three
|
|
6
|
+
* wrote these same four functions by hand, and one of the three wrote `c.json(ok(data))` instead
|
|
7
|
+
* of `c.json(ok(data).body)`: every 200 from a live API answered
|
|
8
|
+
* `{"status":200,"body":{"data":…}}` for fifty minutes. Nothing caught it. `c.json` takes any
|
|
9
|
+
* JSON value, so the types were satisfied; the status was still 200, so every probe and every
|
|
10
|
+
* deploy gate was satisfied; and a test that calls the module never sees the body its caller
|
|
11
|
+
* sends. A client found it, because a client is the only reader that parses the envelope.
|
|
12
|
+
*
|
|
13
|
+
* A doc comment would have been read by whoever was already careful. These make the wrong line
|
|
14
|
+
* unreachable, which is the only fix available to a package that owns both sides of the seam.
|
|
15
|
+
*/
|
|
16
|
+
import type { PaginationMeta } from "@gusnips/http";
|
|
17
|
+
import type { Context } from "hono";
|
|
18
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
19
|
+
import { ok as okBody, paginated as paginatedBody } from "../responses.ts";
|
|
20
|
+
|
|
21
|
+
/** `return ok(c, user)` — or `ok(c, job, 202)` where the route accepted rather than answered. */
|
|
22
|
+
export function ok<T>(c: Context, data: T, status: ContentfulStatusCode = 200) {
|
|
23
|
+
return c.json(okBody(data).body, status);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function created<T>(c: Context, data: T) {
|
|
27
|
+
return ok(c, data, 201);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `hasMore` is computed from the rows actually returned — see `paginated` in `../responses.ts`. */
|
|
31
|
+
export function paginated<T>(c: Context, rows: T[], meta: Omit<PaginationMeta, "hasMore">) {
|
|
32
|
+
return c.json(paginatedBody(rows, meta).body, 200);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `c.body(null, 204)`, not `c.json`: a 204 carries no body, and `c.json(null, 204)` writes the
|
|
37
|
+
* four bytes `null` and a `content-type` header under a status that promises neither.
|
|
38
|
+
*/
|
|
39
|
+
export function noContent(c: Context) {
|
|
40
|
+
return c.body(null, 204);
|
|
41
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
export { AppError, createAppError, toMessage } from "./errors.ts";
|
|
2
2
|
export type { AppErrorOptions } from "./errors.ts";
|
|
3
|
-
export {
|
|
4
|
-
|
|
3
|
+
export {
|
|
4
|
+
created,
|
|
5
|
+
createErrorResponse,
|
|
6
|
+
noContent,
|
|
7
|
+
ok,
|
|
8
|
+
paginated,
|
|
9
|
+
validationIssues,
|
|
10
|
+
} from "./responses.ts";
|
|
11
|
+
export type {
|
|
12
|
+
CannedError,
|
|
13
|
+
ErrorAnswer,
|
|
14
|
+
ErrorResponseOptions,
|
|
15
|
+
ValidationIssue,
|
|
16
|
+
} from "./responses.ts";
|
|
5
17
|
export { createLogger, errorReplacer, keptErrorFields } from "./logger/index.ts";
|
|
6
18
|
export type { Logger, LoggerOptions, LogLevel, LogThreshold } from "./logger/index.ts";
|
|
@@ -221,6 +221,53 @@ describe("what an Error contributes to a log line", () => {
|
|
|
221
221
|
expect(JSON.stringify(line)).not.toContain("4242");
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
it("keeps the stack of an error that crossed a queue and arrived as a plain object", () => {
|
|
225
|
+
// A job queue stores a failed job's error through a serializer, so the dead-letter handler is
|
|
226
|
+
// handed a plain object — and the stack is the whole of the "why" in a line whose job is to
|
|
227
|
+
// say which job died and why. Everything the SDK hung beside it still goes.
|
|
228
|
+
const stored = {
|
|
229
|
+
name: "HttpError",
|
|
230
|
+
message: "Not Found",
|
|
231
|
+
stack: "HttpError: Not Found\n at workers.ts:1:1",
|
|
232
|
+
status: 404,
|
|
233
|
+
request: { method: "GET", body: '{"query":"private"}' },
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const line = JSON.parse(JSON.stringify({ error: stored }, errorReplacer())) as Record<
|
|
237
|
+
string,
|
|
238
|
+
Record<string, unknown>
|
|
239
|
+
>;
|
|
240
|
+
|
|
241
|
+
expect(line.error).toEqual({
|
|
242
|
+
name: "HttpError",
|
|
243
|
+
message: "Not Found",
|
|
244
|
+
stack: "HttpError: Not Found\n at workers.ts:1:1",
|
|
245
|
+
status: 404,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("allow-lists a thrown plain object passed directly as the error", () => {
|
|
250
|
+
// Hono wraps a non-Error throw as a cause, but workers, fire-and-forget catches and a database
|
|
251
|
+
// client's `{ code, message, details }` rejection reach the logger directly. `error` is the
|
|
252
|
+
// raw-error slot the logger documents; an ordinary metadata object under another key stays
|
|
253
|
+
// ordinary metadata.
|
|
254
|
+
const rejection = {
|
|
255
|
+
message: "insert failed",
|
|
256
|
+
code: "23514",
|
|
257
|
+
detail: "Failing row contains (someone@example.com, 4242424242424242).",
|
|
258
|
+
payload: '{"customer_email":"someone@example.com"}',
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const line = serialized(rejection);
|
|
262
|
+
|
|
263
|
+
expect(line).toEqual({
|
|
264
|
+
message: "insert failed",
|
|
265
|
+
code: "23514",
|
|
266
|
+
detail: "[row omitted: Postgres DETAIL for this error is the whole failing row]",
|
|
267
|
+
});
|
|
268
|
+
expect(entryFor({ context: rejection }).context).toEqual(rejection);
|
|
269
|
+
});
|
|
270
|
+
|
|
224
271
|
it("survives a chain of plain-object causes that holds itself", () => {
|
|
225
272
|
// The Error branch has had this guard since it was written; the narrowing builds a new object
|
|
226
273
|
// and so needs its own, or a self-referencing rejection recurses until the stack ends — inside
|
package/src/logger/serialize.ts
CHANGED
|
@@ -91,9 +91,18 @@ function keptValue(key: string, value: unknown): unknown {
|
|
|
91
91
|
* a PostgREST client rejects with plain objects. So in a Hono app the cause slot is precisely where
|
|
92
92
|
* a vendor's rejection object ends up, and a leak there reads as if the list had run.
|
|
93
93
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
94
|
+
* A plain object passed directly as `meta.error` is the other door. Hono wraps it, but a worker,
|
|
95
|
+
* a fire-and-forget catch or a database client outside Hono does not. `error` is the raw-error slot
|
|
96
|
+
* the logger documents, so it gets the same treatment as `cause`; an ordinary metadata object under
|
|
97
|
+
* any other key stays untouched. `name`, `message` and `stack` come along because a rejection
|
|
98
|
+
* object usually carries them and a line with none of them says nothing at all.
|
|
99
|
+
*
|
|
100
|
+
* `stack` is here because an adopter's queue found it missing. A job that dies is stored by its
|
|
101
|
+
* queue through a serializer, so the error reaching the dead-letter handler is a plain object with
|
|
102
|
+
* its stack in a string — and that stack is the whole of the "why" in a line whose job is to say
|
|
103
|
+
* which job died and why. The Error branch below has always written `stack` unfiltered; leaving it
|
|
104
|
+
* out here was an asymmetry, not a decision. It is a conventional field name, not one an SDK hangs
|
|
105
|
+
* its own inputs off, which is what the allow-list exists to stop.
|
|
97
106
|
*
|
|
98
107
|
* Deliberate state it does NOT keep: context an app attaches on purpose. That belongs in the
|
|
99
108
|
* logger's `meta`, which is untouched — `cause` is not the place for it, and one incident of a
|
|
@@ -106,13 +115,15 @@ export function narrowErrorLike(value: object): Record<string, unknown> {
|
|
|
106
115
|
function narrow(value: object, seen: WeakSet<object>): Record<string, unknown> {
|
|
107
116
|
seen.add(value);
|
|
108
117
|
const out: Record<string, unknown> = {};
|
|
109
|
-
const { name, message, cause } = value as {
|
|
118
|
+
const { name, message, stack, cause } = value as {
|
|
110
119
|
name?: unknown;
|
|
111
120
|
message?: unknown;
|
|
121
|
+
stack?: unknown;
|
|
112
122
|
cause?: unknown;
|
|
113
123
|
};
|
|
114
124
|
if (typeof name === "string") out.name = name;
|
|
115
125
|
if (typeof message === "string") out.message = message;
|
|
126
|
+
if (typeof stack === "string") out.stack = stack;
|
|
116
127
|
for (const [k, v] of Object.entries(value)) {
|
|
117
128
|
if (KEPT_ERROR_FIELDS.has(k)) out[k] = keptValue(k, v);
|
|
118
129
|
}
|
|
@@ -139,6 +150,9 @@ function narrowCause(cause: unknown, seen: WeakSet<object>): unknown {
|
|
|
139
150
|
* `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the
|
|
140
151
|
* failure it was called to report. They are added explicitly, and the allow-listed extras ride
|
|
141
152
|
* along beside them.
|
|
153
|
+
* - A plain object in the root `error` slot is narrowed through the same allow-list. Hono's
|
|
154
|
+
* boundary turns one into an Error cause, but workers and swallowed catches log it directly.
|
|
155
|
+
* Other metadata objects stay untouched.
|
|
142
156
|
* - A nested `cause` is followed, and so is an `AggregateError`'s `errors`. Both are
|
|
143
157
|
* non-enumerable, so both are invisible to the loop above; without this line "all attempts
|
|
144
158
|
* failed" is the whole log entry. Each one goes back through this replacer, so the allow-list
|
|
@@ -170,12 +184,17 @@ function narrowCause(cause: unknown, seen: WeakSet<object>): unknown {
|
|
|
170
184
|
*/
|
|
171
185
|
export function errorReplacer(): (this: unknown, key: string, value: unknown) => unknown {
|
|
172
186
|
const seen = new WeakSet<object>();
|
|
187
|
+
let root: object | undefined;
|
|
173
188
|
return function (key, value) {
|
|
174
189
|
const held =
|
|
175
190
|
typeof this === "object" && this !== null
|
|
176
191
|
? (this as Record<string, unknown>)[key]
|
|
177
192
|
: undefined;
|
|
193
|
+
if (key === "" && typeof held === "object" && held !== null) root = held;
|
|
178
194
|
if (held instanceof Error) value = held;
|
|
195
|
+
else if (this === root && key === "error" && typeof held === "object" && held !== null) {
|
|
196
|
+
return seen.has(held) ? "[Circular]" : narrow(held, seen);
|
|
197
|
+
}
|
|
179
198
|
if (typeof value === "bigint") return value.toString();
|
|
180
199
|
if (value instanceof Error) {
|
|
181
200
|
if (seen.has(value)) return "[Circular]";
|
package/src/responses.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { type ValidationIssue, validationIssues } from "./index.ts";
|
|
3
4
|
import { AppError, createAppError } from "./errors.ts";
|
|
4
5
|
import { created, createErrorResponse, noContent, ok, paginated } from "./responses.ts";
|
|
5
6
|
|
|
@@ -110,6 +111,43 @@ describe("a validation failure is a 400 that reflects nothing back", () => {
|
|
|
110
111
|
.object({ days: z.enum(["7", "30"]), timeoutMs: z.number().max(30_000) })
|
|
111
112
|
.strict();
|
|
112
113
|
|
|
114
|
+
it("exports the safe issue projection for every door that validates input", () => {
|
|
115
|
+
const validationError = {
|
|
116
|
+
issues: [
|
|
117
|
+
{
|
|
118
|
+
path: ["items", 0, Symbol("field"), Symbol()],
|
|
119
|
+
code: "too_big",
|
|
120
|
+
maximum: 10,
|
|
121
|
+
minimum: 1,
|
|
122
|
+
received: "secret",
|
|
123
|
+
values: ["private", "schema"],
|
|
124
|
+
message: "Expected a private schema value",
|
|
125
|
+
},
|
|
126
|
+
{ path: ["amount"], code: "too_big", maximum: 10n, minimum: "1" },
|
|
127
|
+
],
|
|
128
|
+
} as const;
|
|
129
|
+
const projected: ValidationIssue[] = validationIssues(validationError);
|
|
130
|
+
|
|
131
|
+
expect(projected).toEqual([
|
|
132
|
+
{ path: ["items", 0, "field", ""], code: "too_big", maximum: 10, minimum: 1 },
|
|
133
|
+
{ path: ["amount"], code: "too_big" },
|
|
134
|
+
]);
|
|
135
|
+
expect(() => JSON.stringify(projected)).not.toThrow();
|
|
136
|
+
expect(JSON.stringify(projected)).not.toContain("secret");
|
|
137
|
+
expect(JSON.stringify(projected)).not.toContain("private");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("answers rather than throws when an issue arrives without a usable path", () => {
|
|
141
|
+
// The gate admits anything named ZodError with an array of issues, so an element that lost a
|
|
142
|
+
// field crossing a queue or a tool boundary reaches the projection. Throwing here throws
|
|
143
|
+
// inside the function whose whole job is to turn a thrown thing into an answer.
|
|
144
|
+
for (const issue of [{ code: "custom" }, null, { path: "amount", code: "too_big" }]) {
|
|
145
|
+
const answer = errorResponse({ name: "ZodError", issues: [issue] });
|
|
146
|
+
expect(answer.status).toBe(400);
|
|
147
|
+
expect(answer.body.error.details).toEqual([{ path: [], code: issue?.code ?? "" }]);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
113
151
|
it("answers 400 with the path and the rule", () => {
|
|
114
152
|
const parsed = schema.safeParse({ days: "90", timeoutMs: 1 });
|
|
115
153
|
const answer = errorResponse(parsed.error);
|
package/src/responses.ts
CHANGED
|
@@ -13,7 +13,20 @@
|
|
|
13
13
|
import type { ApiError, ApiSuccess, PaginationMeta } from "@gusnips/http";
|
|
14
14
|
import { AppError } from "./errors.ts";
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The `body` is `{ data }`, or `{ data, meta }` where a route has counts to report.
|
|
18
|
+
*
|
|
19
|
+
* What comes back is an ANSWER — `{ status, body }` — not a body, because this layer is
|
|
20
|
+
* framework-free and has to hand its caller a status too. An adapter takes `.body`:
|
|
21
|
+
*
|
|
22
|
+
* return c.json(ok(data), status); // WRONG: {"status":200,"body":{"data":…}}
|
|
23
|
+
* return c.json(ok(data).body, status); // the envelope
|
|
24
|
+
*
|
|
25
|
+
* Nothing catches the first line — `c.json` takes any JSON value, the status is still whatever
|
|
26
|
+
* you passed, and a test that calls this module never sees the body its caller sends. It shipped,
|
|
27
|
+
* and a client found it. On Hono, import `ok` from `@gusnips/server/hono` instead: the four
|
|
28
|
+
* adapters there take the `Context`, and the question does not arise.
|
|
29
|
+
*/
|
|
17
30
|
export function ok<T, M = PaginationMeta>(data: T, meta?: M) {
|
|
18
31
|
const body: ApiSuccess<T, M> = meta === undefined ? { data } : { data, meta };
|
|
19
32
|
return { status: 200 as const, body };
|
|
@@ -152,11 +165,27 @@ function envelope<Code extends string, Key extends string>(
|
|
|
152
165
|
};
|
|
153
166
|
}
|
|
154
167
|
|
|
168
|
+
/**
|
|
169
|
+
* What an issue MIGHT carry — every field optional, because the gate below proves only that
|
|
170
|
+
* `issues` is an array and nothing at all about an element. Typing the element as certain is
|
|
171
|
+
* what turned this projection into a throw: `path.map` on an issue that arrived without one.
|
|
172
|
+
*/
|
|
155
173
|
interface RawIssue {
|
|
156
|
-
path?: unknown;
|
|
157
|
-
code?: unknown;
|
|
158
|
-
maximum?: unknown;
|
|
159
|
-
minimum?: unknown;
|
|
174
|
+
readonly path?: unknown;
|
|
175
|
+
readonly code?: unknown;
|
|
176
|
+
readonly maximum?: unknown;
|
|
177
|
+
readonly minimum?: unknown;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One rejected field: enough to fix the call, and nothing about the schema. */
|
|
181
|
+
export interface ValidationIssue {
|
|
182
|
+
/** The field that failed, as the caller spelled it. */
|
|
183
|
+
path: (string | number)[];
|
|
184
|
+
/** The rule that rejected it, such as `too_big`. */
|
|
185
|
+
code: string;
|
|
186
|
+
/** The numeric bound, when the rule has one. */
|
|
187
|
+
maximum?: number;
|
|
188
|
+
minimum?: number;
|
|
160
189
|
}
|
|
161
190
|
|
|
162
191
|
/**
|
|
@@ -174,6 +203,18 @@ function zodIssues(err: unknown): RawIssue[] | null {
|
|
|
174
203
|
return issues as RawIssue[];
|
|
175
204
|
}
|
|
176
205
|
|
|
206
|
+
/**
|
|
207
|
+
* One path segment, as something that survives `JSON.stringify`.
|
|
208
|
+
*
|
|
209
|
+
* A symbol keyed a field the caller cannot name back at us, so its description is the only
|
|
210
|
+
* useful thing in it — and an unnamed symbol has none, which is an empty segment rather than
|
|
211
|
+
* the `null` that `JSON.stringify` would otherwise write.
|
|
212
|
+
*/
|
|
213
|
+
function pathSegment(segment: unknown): string | number {
|
|
214
|
+
if (typeof segment === "symbol") return segment.description ?? "";
|
|
215
|
+
return typeof segment === "number" ? segment : String(segment);
|
|
216
|
+
}
|
|
217
|
+
|
|
177
218
|
/**
|
|
178
219
|
* The field path, the rule it failed, and — for a range — the BOUND it failed against.
|
|
179
220
|
*
|
|
@@ -187,14 +228,24 @@ function zodIssues(err: unknown): RawIssue[] | null {
|
|
|
187
228
|
*
|
|
188
229
|
* The bound is the exception, and it belongs to the caller: it is the published contract, and
|
|
189
230
|
* a `too_big` without it costs somebody a bisect to rediscover a number our own docs state.
|
|
231
|
+
*
|
|
232
|
+
* Total on purpose: this runs inside the function that turns a thrown thing into an answer, and
|
|
233
|
+
* it is advertised to the queue and tool doors, where an issue list has crossed a serialization
|
|
234
|
+
* hop. An allow-list that throws on a malformed issue sends its caller back to shipping the
|
|
235
|
+
* validator's issues raw, which is the disclosure it exists to prevent.
|
|
190
236
|
*/
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
237
|
+
export function validationIssues(error: {
|
|
238
|
+
readonly issues: readonly RawIssue[];
|
|
239
|
+
}): ValidationIssue[] {
|
|
240
|
+
return error.issues.map((issue): ValidationIssue => {
|
|
241
|
+
const { path, code, maximum, minimum } = issue ?? {};
|
|
242
|
+
return {
|
|
243
|
+
path: Array.isArray(path) ? path.map(pathSegment) : [],
|
|
244
|
+
code: typeof code === "string" ? code : "",
|
|
245
|
+
...(typeof maximum === "number" && { maximum }),
|
|
246
|
+
...(typeof minimum === "number" && { minimum }),
|
|
247
|
+
};
|
|
248
|
+
});
|
|
198
249
|
}
|
|
199
250
|
|
|
200
251
|
/**
|
|
@@ -233,7 +284,7 @@ export function createErrorResponse<Code extends string = string, Key extends st
|
|
|
233
284
|
if (issues !== null) {
|
|
234
285
|
return {
|
|
235
286
|
status: 400,
|
|
236
|
-
body: envelope(opts.validation,
|
|
287
|
+
body: envelope(opts.validation, validationIssues({ issues })),
|
|
237
288
|
headers: {},
|
|
238
289
|
kind: "client",
|
|
239
290
|
};
|