Skip to content

Commit d4c2e9b

Browse files
committed
feat: http.err to require custom message
1 parent ba47118 commit d4c2e9b

7 files changed

Lines changed: 284 additions & 42 deletions

File tree

.cspell.jsonc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
"unqueryable",
3636
"unvalidated",
3737
"travelling",
38+
"Refusé",
39+
"Réessayez",
40+
"dans",
3841
// test fixture variable names (wuser, xuser, puser)
3942
"wuser",
4043
"xuser",

packages/experimental/src/error.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,23 +88,28 @@ export class ValidationError extends Error {
8888
* `trail` records the fn that threw, then every frame it crossed.
8989
* Serializes as data, so it survives a remote boundary intact.
9090
* Optional `status` is set when the declaration carried HTTP metadata
91-
* (`http.err`); the edge uses it without looking the map back up.
91+
* (`http.err`); the declared human message becomes `Error.message` and
92+
* `toJSON().message` so the edge can rewrite it for i18n.
9293
*/
9394
export class FnError<
9495
Tag extends string = string,
9596
Data = unknown,
9697
> extends Error {
9798
public trail: string[];
9899
public status?: number;
100+
/** True when `message` came from `http.err` (vs the `${fn}: ${tag}` fallback). */
101+
readonly #declaredMessage: boolean;
99102
constructor(
100103
public tag: Tag,
101104
public data: Data,
102105
fn: string,
103106
status?: number,
107+
message?: string,
104108
) {
105-
super(`${fn}: ${tag}`);
109+
super(message ?? `${fn}: ${tag}`);
106110
this.name = "FnError";
107111
this.trail = [fn];
112+
this.#declaredMessage = message !== undefined;
108113
if (status !== undefined) this.status = status;
109114
scrubLibraryFrames(this);
110115
}
@@ -115,6 +120,7 @@ export class FnError<
115120
data: this.data,
116121
trail: this.trail,
117122
...(this.status !== undefined ? { status: this.status } : {}),
123+
...(this.#declaredMessage ? { message: this.message } : {}),
118124
};
119125
}
120126
}

packages/experimental/src/fn.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,18 +1086,19 @@ const defineFn = (
10861086
: `"${key}" declares no errors`,
10871087
);
10881088
}
1089-
// HTTP status lives on the ORIGINAL declaration (`http.err`);
1089+
// HTTP status/message live on the ORIGINAL declaration (`http.err`);
10901090
// `asType` copies enumerable fields only, so read it there.
10911091
const meta = (
10921092
declaredErrors?.[tag] as
1093-
| Record<symbol, { status?: number } | undefined>
1093+
| Record<symbol, { status?: number; message?: string } | undefined>
10941094
| undefined
10951095
)?.[Symbol.for("better-call:http.err")];
10961096
const err = new FnError(
10971097
tag,
10981098
validate(schema, data ?? {}, `${key}.errors.${tag}`),
10991099
key,
11001100
meta?.status,
1101+
meta?.message,
11011102
);
11021103
return captureCallerStack(err, mintError);
11031104
};

packages/experimental/src/plugins/http/error.test.ts

Lines changed: 168 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,45 +13,52 @@ import {
1313

1414
const app = v.fn({ use: [http] });
1515

16-
describe("http.err - status on declared errors", () => {
17-
it("stashes status without polluting enumerable fields", () => {
18-
const decl = err(401, { attempts: v.number() });
16+
describe("http.err - status + message on declared errors", () => {
17+
it("stashes status and message without polluting enumerable fields", () => {
18+
const decl = err(401, "Invalid credentials", { attempts: v.number() });
1919
expect(statusOf(decl)).toBe(401);
2020
expect(Object.keys(decl)).toEqual(["attempts"]);
2121
expect((decl as Record<symbol, unknown>)[kHttpErr]).toEqual({
2222
status: 401,
23+
message: "Invalid credentials",
2324
});
2425
});
2526

26-
it("status-only declarations stay empty payloads", () => {
27-
const decl = err(410);
27+
it("message-only declarations stay empty payloads", () => {
28+
const decl = err(410, "Gone");
2829
expect(statusOf(decl)).toBe(410);
2930
expect(Object.keys(decl)).toEqual([]);
31+
expect((decl as Record<symbol, unknown>)[kHttpErr]).toEqual({
32+
status: 410,
33+
message: "Gone",
34+
});
3035
});
3136

3237
it("refuses non-HTTP status numbers", () => {
33-
expect(() => err(99)).toThrow(/http\.err\.status/);
34-
expect(() => err(600)).toThrow(/http\.err\.status/);
35-
expect(() => err(401.5)).toThrow(/http\.err\.status/);
38+
expect(() => err(99, "nope")).toThrow(/http\.err\.status/);
39+
expect(() => err(600, "nope")).toThrow(/http\.err\.status/);
40+
expect(() => err(401.5, "nope")).toThrow(/http\.err\.status/);
3641
});
3742

3843
it("copies the data shape so shared schemas do not share meta", () => {
3944
const shared = { attempts: v.number() };
40-
const a = err(401, shared);
41-
const b = err(403, shared);
45+
const a = err(401, "a", shared);
46+
const b = err(403, "b", shared);
4247
expect(statusOf(a)).toBe(401);
4348
expect(statusOf(b)).toBe(403);
4449
expect(statusOf(shared)).toBeUndefined();
4550
});
4651

47-
it("c.error still validates payload; status never enters data", () => {
52+
it("c.error still validates payload; status and message never enter data", () => {
4853
const signIn = app.fn(
4954
"httpt.err.sign_in",
5055
{
5156
input: { kind: v.string() },
5257
errors: {
53-
invalid_credentials: err(401, { attempts: v.number() }),
54-
gone: err(410),
58+
invalid_credentials: err(401, "Invalid credentials", {
59+
attempts: v.number(),
60+
}),
61+
gone: err(410, "Gone"),
5562
},
5663
},
5764
(c) => {
@@ -67,6 +74,7 @@ describe("http.err - status on declared errors", () => {
6774
expect(bad.error.tag).toBe("invalid_credentials");
6875
expect(bad.error.data).toEqual({ attempts: 3 });
6976
expect(bad.error.status).toBe(401);
77+
expect(bad.error.message).toBe("Invalid credentials");
7078
if (bad.error.tag === "invalid_credentials") {
7179
expectTypeOf(bad.error.data).toEqualTypeOf<{
7280
readonly attempts: number;
@@ -80,12 +88,17 @@ describe("http.err - status on declared errors", () => {
8088
expect(gone.error.tag).toBe("gone");
8189
expect(gone.error.data).toEqual({});
8290
expect(gone.error.status).toBe(410);
91+
expect(gone.error.message).toBe("Gone");
8392
}
8493

8594
expect(() =>
8695
app.fn(
8796
"httpt.err.liar",
88-
{ errors: { oops: err(400, { code: v.number() }) } },
97+
{
98+
errors: {
99+
oops: err(400, "Oops", { code: v.number() }),
100+
},
101+
},
89102
(c) => {
90103
throw c.error("oops", { code: "nope" } as never);
91104
},
@@ -98,7 +111,7 @@ describe("http.err - status on declared errors", () => {
98111
"httpt.err.guard",
99112
{
100113
errors: {
101-
denied: err(403),
114+
denied: err(403, "Denied"),
102115
plain: { reason: v.string() },
103116
},
104117
},
@@ -118,8 +131,8 @@ describe("http.err - status on declared errors", () => {
118131
"httpt.err.endpoint",
119132
{
120133
errors: {
121-
unauthorized: err(401, { attempts: v.number() }),
122-
conflict: err(409),
134+
unauthorized: err(401, "Unauthorized", { attempts: v.number() }),
135+
conflict: err(409, "Conflict"),
123136
},
124137
},
125138
(c) => {
@@ -149,8 +162,8 @@ describe("http.err - status on declared errors", () => {
149162
});
150163

151164
it("http.err is re-exported on the http module", () => {
152-
expect(http.err(418)).toBeDefined();
153-
expect(http.statusOf(http.err(418))).toBe(418);
165+
expect(http.err(418, "I'm a teapot")).toBeDefined();
166+
expect(http.statusOf(http.err(418, "I'm a teapot"))).toBe(418);
154167
});
155168
});
156169

@@ -216,10 +229,10 @@ describe("encodeError + createHandler JSON bodies", () => {
216229
});
217230
});
218231

219-
it("createHandler returns declared status JSON for FnError", async () => {
232+
it("createHandler returns declared status + message JSON for FnError", async () => {
220233
const deny = app.fn(
221234
"httpt.err.deny",
222-
{ errors: { denied: err(403) } },
235+
{ errors: { denied: err(403, "Denied") } },
223236
(c) => {
224237
throw c.error("denied");
225238
},
@@ -235,6 +248,140 @@ describe("encodeError + createHandler JSON bodies", () => {
235248
data: {},
236249
trail: ["httpt.err.deny"],
237250
status: 403,
251+
message: "Denied",
252+
});
253+
});
254+
255+
it("encodeError rewrites FnError message by tag and keeps originalMessage", () => {
256+
const stamped = new FnError("denied", {}, "f", 403, "Denied");
257+
expect(
258+
encodeError(stamped, {
259+
messages: { denied: "Refusé" },
260+
}),
261+
).toEqual({
262+
status: 403,
263+
body: {
264+
name: "FnError",
265+
tag: "denied",
266+
data: {},
267+
trail: ["f"],
268+
status: 403,
269+
message: "Refusé",
270+
originalMessage: "Denied",
271+
},
272+
});
273+
});
274+
275+
it("encodeError message fn can interpolate data", () => {
276+
const stamped = new FnError(
277+
"rate_limited",
278+
{ retryAfter: 30 },
279+
"f",
280+
429,
281+
"Too many attempts",
282+
);
283+
expect(
284+
encodeError(stamped, {
285+
message: (error) =>
286+
error.tag === "rate_limited"
287+
? `Réessayez dans ${(error.data as { retryAfter: number }).retryAfter}s`
288+
: error.message,
289+
})?.body,
290+
).toMatchObject({
291+
message: "Réessayez dans 30s",
292+
originalMessage: "Too many attempts",
293+
});
294+
});
295+
296+
it("encodeError messages fn can pick a locale from the request", () => {
297+
const stamped = new FnError("denied", {}, "f", 403, "Denied");
298+
const request = new Request("http://x.test/", {
299+
headers: { "Accept-Language": "fr" },
300+
});
301+
expect(
302+
encodeError(stamped, {
303+
request,
304+
messages: (req) => {
305+
const locale = req?.headers.get("Accept-Language") ?? "en";
306+
return {
307+
en: { denied: "Denied" },
308+
fr: { denied: "Refusé" },
309+
}[locale];
310+
},
311+
})?.body,
312+
).toMatchObject({
313+
message: "Refusé",
314+
originalMessage: "Denied",
315+
});
316+
});
317+
318+
it("createHandler message fn receives the request", async () => {
319+
const deny = app.fn(
320+
"httpt.err.deny_req",
321+
{ errors: { denied: err(403, "Denied") } },
322+
(c) => {
323+
throw c.error("denied");
324+
},
325+
);
326+
const fetch = http.createHandler(
327+
() => {
328+
throw deny();
329+
},
330+
{
331+
message: (error, request) => {
332+
if (request?.headers.get("Accept-Language") === "fr") {
333+
return "Refusé";
334+
}
335+
return error.message;
336+
},
337+
},
338+
);
339+
const response = await fetch(
340+
new Request("http://x.test/", {
341+
headers: { "Accept-Language": "fr" },
342+
}),
343+
);
344+
expect(response.status).toBe(403);
345+
await expect(response.json()).resolves.toMatchObject({
346+
message: "Refusé",
347+
originalMessage: "Denied",
348+
});
349+
});
350+
351+
it("encodeError leaves message alone when no override matches", () => {
352+
const stamped = new FnError("denied", {}, "f", 403, "Denied");
353+
expect(encodeError(stamped, { messages: { other: "x" } })?.body).toEqual({
354+
name: "FnError",
355+
tag: "denied",
356+
data: {},
357+
trail: ["f"],
358+
status: 403,
359+
message: "Denied",
360+
});
361+
});
362+
363+
it("createHandler applies messages overrides from options", async () => {
364+
const deny = app.fn(
365+
"httpt.err.deny_i18n",
366+
{ errors: { denied: err(403, "Denied") } },
367+
(c) => {
368+
throw c.error("denied");
369+
},
370+
);
371+
const fetch = http.createHandler(
372+
() => {
373+
throw deny();
374+
},
375+
{
376+
messages: { denied: "Refusé" },
377+
},
378+
);
379+
const response = await fetch(new Request("http://x.test/"));
380+
expect(response.status).toBe(403);
381+
await expect(response.json()).resolves.toMatchObject({
382+
tag: "denied",
383+
message: "Refusé",
384+
originalMessage: "Denied",
238385
});
239386
});
240387
});

0 commit comments

Comments
 (0)