Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ console.log(JSON.stringify(body));

// An Error you can throw. Its .message is the code, so logs stay searchable.
// The sentence comes from describe().
// Starting from a raw failure, problemFor() classifies it and keeps the
// observed status — including when the matched code registers several.
const fromRaw = errors.problemFor({ status: 422 });
console.log(JSON.stringify(fromRaw));

const err = errors.create("config.missing", { field: "API_KEY" });
console.log(err.message);
console.log(errors.describe(err.code, err.params));
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Everything else is your catalog.

`Registry` methods: `classify(raw)`, `describe(code, params?, t?)` (`t` is any
`Translator`, including i18next's `t` as-is),
`toProblemDetails(code, params?, opts?)`, `create(code, params?)`, plus
`toProblemDetails(code, params?, opts?)`, `problemFor(raw, params?, opts?)`, `create(code, params?)`, plus
`codes`, `has`, and `get`.

`CatalogEntry` fields: `category` (required), `en`, `params`, `i18nKey`,
Expand Down
16 changes: 16 additions & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,21 @@ function createRegistry<C extends Catalog>(
return problem;
}

function problemFor(
raw: unknown,
params?: Params,
options?: ProblemOptions,
): ProblemDetails {
const code = classify(raw);
const observed = httpStatusOf(raw);
const status = own(options, "status") ?? observed;
return toProblemDetails(
code,
params,
status === undefined ? options : { ...options, status },
);
}

function create(code: ErrorCode, params?: Params): CanonicalError {
const category = own(entryOf(code), "category") ?? "internal";
return new CanonicalError(code, category, params);
Expand All @@ -345,6 +360,7 @@ function createRegistry<C extends Catalog>(
classify,
describe,
toProblemDetails,
problemFor,
create,
};
return registry as Registry<C>;
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ export interface Registry<C extends Catalog = Catalog> {
/** Turn a raw transport/LLM failure into a code (this registry's fallback). */
classify(raw: unknown): ErrorCode;

/** Classify a raw failure AND serialize it, preserving the observed HTTP
* status when the matched code registers more than one (`httpStatus: [400,
* 422]`). `options.status` still wins. */
problemFor(
raw: unknown,
params?: Params,
options?: ProblemOptions,
): ProblemDetails;

/** Resolve the human text for a code via i18n, falling back to English. */
describe<K extends keyof C & string>(
code: K,
Expand Down
48 changes: 48 additions & 0 deletions test/problem-for.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { defineErrors } from "../src/index.js";

const errors = defineErrors({
"api.multi": {
category: "request",
en: "The request was rejected.",
httpStatus: [400, 422],
},
"api.one": {
category: "request",
en: "The request was rejected once.",
httpStatus: [409],
},
"internal.unknown": {
category: "internal",
en: "Something went wrong.",
},
});

describe("problemFor preserves the observed status", () => {
it("uses the status that matched, not the first status the code registers", () => {
expect(errors.problemFor({ status: 422 }, { field: "email" }).status).toBe(
422,
);
expect(errors.problemFor({ status: 400 }).status).toBe(400);
});

it("still uses the single registered status for a one-status code", () => {
expect(errors.problemFor({ status: 409 }).status).toBe(409);
});

it("omits status when the raw failure has none", () => {
expect(errors.problemFor({ message: "boom" }).status).toBeUndefined();
});

it("lets the caller override the observed status", () => {
expect(errors.problemFor({ status: 422 }, {}, { status: 502 }).status).toBe(
502,
);
});

it("keeps the classified code and its params", () => {
const body = errors.problemFor({ status: 422 }, { field: "email" });
expect(body.type).toBe("api.multi");
expect(body.field).toBe("email");
});
});
Loading