@setu-ts/exceptions 0.0.0-stage → 0.10.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/AGENT_CARD.md +50 -0
- package/LICENSE +21 -0
- package/README.md +188 -2
- package/dist/errors/exceptions.d.ts +146 -0
- package/dist/errors/exceptions.d.ts.map +1 -0
- package/dist/errors/exceptions.js +189 -0
- package/dist/errors/exceptions.js.map +1 -0
- package/dist/errors/http-error.d.ts +111 -0
- package/dist/errors/http-error.d.ts.map +1 -0
- package/dist/errors/http-error.js +81 -0
- package/dist/errors/http-error.js.map +1 -0
- package/dist/formatters/error-formatter.d.ts +81 -0
- package/dist/formatters/error-formatter.d.ts.map +1 -0
- package/dist/formatters/error-formatter.js +58 -0
- package/dist/formatters/error-formatter.js.map +1 -0
- package/dist/formatters/problem-details.d.ts +118 -0
- package/dist/formatters/problem-details.d.ts.map +1 -0
- package/dist/formatters/problem-details.js +108 -0
- package/dist/formatters/problem-details.js.map +1 -0
- package/dist/formatters/rfc7807-formatter.d.ts +20 -0
- package/dist/formatters/rfc7807-formatter.d.ts.map +1 -0
- package/dist/formatters/rfc7807-formatter.js +27 -0
- package/dist/formatters/rfc7807-formatter.js.map +1 -0
- package/dist/formatters/rfc9457-formatter.d.ts +29 -0
- package/dist/formatters/rfc9457-formatter.d.ts.map +1 -0
- package/dist/formatters/rfc9457-formatter.js +43 -0
- package/dist/formatters/rfc9457-formatter.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +111 -0
- package/dist/middleware/error-handler.d.ts.map +1 -0
- package/dist/middleware/error-handler.js +221 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/middleware/error-responder-impl.d.ts +57 -0
- package/dist/middleware/error-responder-impl.d.ts.map +1 -0
- package/dist/middleware/error-responder-impl.js +158 -0
- package/dist/middleware/error-responder-impl.js.map +1 -0
- package/package.json +53 -3
- package/src/errors/exceptions.ts +209 -0
- package/src/errors/http-error.ts +132 -0
- package/src/formatters/error-formatter.ts +127 -0
- package/src/formatters/problem-details.ts +177 -0
- package/src/formatters/rfc7807-formatter.ts +48 -0
- package/src/formatters/rfc9457-formatter.ts +69 -0
- package/src/index.ts +57 -0
- package/src/middleware/error-handler.ts +357 -0
- package/src/middleware/error-responder-impl.ts +192 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { httpStatusHintOf, resolveResponseStatus } from '@setu-ts/common';
|
|
2
|
+
import { HttpError } from "../errors/http-error.js";
|
|
3
|
+
import { RESPONDER_DETAIL } from "../formatters/problem-details.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `application/problem+json` content type for Problem Details responses.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the constant `errorHandler` uses to decide the media type: a
|
|
8
|
+
* formatter answers Problem Details (and so derives `instance` from the
|
|
9
|
+
* request path) exactly when its resolved content type is this one.
|
|
10
|
+
*/
|
|
11
|
+
const PROBLEM_JSON = 'application/problem+json';
|
|
12
|
+
/** The pure thrown-value rule, shared by serving and request-log prediction. */
|
|
13
|
+
export function thrownErrorStatus(error) {
|
|
14
|
+
const requested = error instanceof HttpError ? error.statusCode : (httpStatusHintOf(error)?.status ?? 500);
|
|
15
|
+
// Fetch refuses informational responses and bodies on 204, 205 and 304.
|
|
16
|
+
const status = Number.isInteger(requested) &&
|
|
17
|
+
requested >= 200 &&
|
|
18
|
+
requested <= 599 &&
|
|
19
|
+
requested !== 204 &&
|
|
20
|
+
requested !== 205 &&
|
|
21
|
+
requested !== 304
|
|
22
|
+
? requested
|
|
23
|
+
: 500;
|
|
24
|
+
return { requested, status };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Narrows an {@linkcode ErrorResponderTarget} to a full {@linkcode
|
|
28
|
+
* IRequestContext} when it genuinely is one.
|
|
29
|
+
*
|
|
30
|
+
* The responder is reached two ways. In-pipeline sites — the kernel's terminals
|
|
31
|
+
* and every short-circuiting middleware — pass the live `IRequestContext`,
|
|
32
|
+
* which carries `id`, `services`, and `startTime`. The kernel's PRE-pipeline
|
|
33
|
+
* sites — the shutdown-drain `503` and the malformed-request `400` — pass a
|
|
34
|
+
* bare target of just `state`, `response`, and an optional safe `request`
|
|
35
|
+
* path, because no request context exists yet. The three members checked here
|
|
36
|
+
* are present on every real context and absent from every bare target, so the
|
|
37
|
+
* test distinguishes the two without a lie.
|
|
38
|
+
*
|
|
39
|
+
* @param target - The responder target to test
|
|
40
|
+
* @returns `true` when the target is a full request context
|
|
41
|
+
*/
|
|
42
|
+
function isFullRequestContext(target) {
|
|
43
|
+
const ctx = target;
|
|
44
|
+
return (typeof ctx.id === 'string' &&
|
|
45
|
+
typeof ctx.services === 'object' &&
|
|
46
|
+
ctx.services !== null &&
|
|
47
|
+
typeof ctx.startTime === 'number');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Builds the {@linkcode HttpError} that carries one error response's status,
|
|
51
|
+
* title and disclosure.
|
|
52
|
+
*
|
|
53
|
+
* This is the single owner of that mapping, and it has **two** callers: the
|
|
54
|
+
* responder below, and `errorHandler`'s status-hint path (M89b), which answers
|
|
55
|
+
* a thrown error branded with an `HttpStatusHint` — a type that IS an
|
|
56
|
+
* `ErrorResponseInit`. Sharing it is what makes the two agree: a hinted throw
|
|
57
|
+
* and a `respondWithError` call carrying the same values produce byte-identical
|
|
58
|
+
* bodies under `default`, `rfc9457`, `rfc7807` and any custom formatter. A
|
|
59
|
+
* second hand-rolled construction would silently diverge — the `default`
|
|
60
|
+
* formatter reads `details.detail` while the Problem Details formatters read
|
|
61
|
+
* the {@linkcode RESPONDER_DETAIL} symbol, so getting either half wrong loses
|
|
62
|
+
* the disclosure in exactly one format.
|
|
63
|
+
*
|
|
64
|
+
* @param init - The status, title, disclosure and optional structured details
|
|
65
|
+
* @returns An `HttpError` every formatter renders with the disclosure intact
|
|
66
|
+
*/
|
|
67
|
+
export function buildErrorFromInit(init) {
|
|
68
|
+
// The title is the framework-default `error` member — the `message` of the
|
|
69
|
+
// `default` format and the `detail` fallback of the Problem Details
|
|
70
|
+
// formatters — so it stays `error.message`. The disclosure (`init.detail`)
|
|
71
|
+
// is kept verbatim by every format (the `ErrorResponseInit` contract in
|
|
72
|
+
// `@setu-ts/common`): it is carried in the error's structured `details`
|
|
73
|
+
// under a `detail` key, so the `default` format emits it as `details.detail`
|
|
74
|
+
// beside `message`, and `buildProblemDetails` promotes it to the Problem
|
|
75
|
+
// Details `detail` member (falling back to the title when a site supplies no
|
|
76
|
+
// disclosure). `init.details` (e.g. a validation `errors` array) is merged
|
|
77
|
+
// through unchanged. Carrying it this way — rather than in `error.message` —
|
|
78
|
+
// keeps the title verbatim, which a site's (non-)disclosure decision depends
|
|
79
|
+
// on.
|
|
80
|
+
const details = init.detail !== undefined ? { ...init.details, detail: init.detail } : init.details;
|
|
81
|
+
const error = new HttpError(init.status, init.title, details);
|
|
82
|
+
// Mark the error as responder-built and carry the disclosure on a
|
|
83
|
+
// module-private symbol, which is what `buildProblemDetails` promotes to the
|
|
84
|
+
// Problem Details `detail` member. The `details.detail` key above is what the
|
|
85
|
+
// `default` formatter emits (it serializes `details` verbatim); the Problem
|
|
86
|
+
// Details formatters read the symbol instead, so a THROWN error that happens
|
|
87
|
+
// to carry a `details.detail` key of its own keeps `detail: <message>` (M70f
|
|
88
|
+
// code review, finding 4). Non-enumerable so it never reaches a serialized
|
|
89
|
+
// body.
|
|
90
|
+
if (init.detail !== undefined) {
|
|
91
|
+
Object.defineProperty(error, RESPONDER_DETAIL, {
|
|
92
|
+
value: init.detail,
|
|
93
|
+
enumerable: false,
|
|
94
|
+
writable: false,
|
|
95
|
+
configurable: true,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return error;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Builds an {@linkcode IErrorResponder} from the formatter and content type
|
|
102
|
+
* that `errorHandler` has **already** resolved at factory time.
|
|
103
|
+
*
|
|
104
|
+
* The responder keeps the `HttpError` construction inside the only package that
|
|
105
|
+
* owns it: it builds an `HttpError` from the init (so `buildProblemDetails` sees
|
|
106
|
+
* a real `statusCode` and the validation `errors` extension, and
|
|
107
|
+
* `maskInternalErrors` would never mask a deliberately 4xx), runs the resolved
|
|
108
|
+
* formatter over it, and writes status, `content-type` and the serialized body
|
|
109
|
+
* — the same three-step tail `errorHandler`'s catch path performs.
|
|
110
|
+
*
|
|
111
|
+
* @param formatter - The resolved error formatter
|
|
112
|
+
* @param contentType - The resolved response content type
|
|
113
|
+
* @returns A responder publishing the application's error format
|
|
114
|
+
* @since 0.1.0
|
|
115
|
+
*/
|
|
116
|
+
export function createErrorResponder(formatter, contentType) {
|
|
117
|
+
return {
|
|
118
|
+
statusFor: (error) => thrownErrorStatus(error).status,
|
|
119
|
+
respond(target, init) {
|
|
120
|
+
// Sanitized before the error is built, not just before the status is
|
|
121
|
+
// written, so the formatted body's `status` member agrees with the status
|
|
122
|
+
// actually written. Idempotent, so the common path through
|
|
123
|
+
// `respondWithError` — which already sanitized — reports nothing twice.
|
|
124
|
+
const status = resolveResponseStatus(init.status, target);
|
|
125
|
+
const error = buildErrorFromInit(status === init.status ? init : { ...init, status });
|
|
126
|
+
// The formatter's `ctx` parameter is a FULL `IRequestContext` by its
|
|
127
|
+
// contract (M70f re-review round 2, finding 2). The target is a full
|
|
128
|
+
// context only at the in-pipeline sites; the kernel's pre-pipeline sites
|
|
129
|
+
// hand over a bare target whose `services`, `id`, `params`, `query`,
|
|
130
|
+
// `signal` and `startTime` do not exist. Passing that partial object
|
|
131
|
+
// would let a custom formatter read a documented member and throw
|
|
132
|
+
// `TypeError: Cannot read properties of undefined` — replacing the
|
|
133
|
+
// configured 503/400 with an unhandled exception. So the formatter
|
|
134
|
+
// receives the context only when it genuinely is one, and `undefined`
|
|
135
|
+
// otherwise — the value its optional `ctx` parameter already declares.
|
|
136
|
+
const fullContext = isFullRequestContext(target) ? target : undefined;
|
|
137
|
+
let body = formatter(error, fullContext);
|
|
138
|
+
// The Problem Details `instance` member comes from the request path. At
|
|
139
|
+
// the in-pipeline sites the formatter read it off the full context; at
|
|
140
|
+
// the pre-pipeline sites there is no context, so the responder supplies
|
|
141
|
+
// the safe path the kernel captured separately — a separate internal
|
|
142
|
+
// mechanism, since the formatter's optional `ctx` parameter is the only
|
|
143
|
+
// public channel and it must stay honest (M70f re-review round 2,
|
|
144
|
+
// finding 2). A malformed request carries no path, in which case the
|
|
145
|
+
// member is simply absent. A fresh object is composed rather than
|
|
146
|
+
// mutating the formatter's return value, which a custom formatter may
|
|
147
|
+
// share or freeze.
|
|
148
|
+
if (fullContext === undefined &&
|
|
149
|
+
contentType === PROBLEM_JSON &&
|
|
150
|
+
target.request !== undefined) {
|
|
151
|
+
body = { ...body, instance: target.request.path };
|
|
152
|
+
}
|
|
153
|
+
const bytes = new TextEncoder().encode(JSON.stringify(body));
|
|
154
|
+
target.response.status(status).header('content-type', contentType).send(bytes);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=error-responder-impl.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error-responder-impl.js","sourceRoot":"","sources":["../../src/middleware/error-responder-impl.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@setu-ts/exceptions",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Exception hierarchy, RFC 9457 Problem Details, and the error-handler middleware",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/setu-ts/setu-ts.git",
|
|
8
|
+
"directory": "packages/exceptions"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/setu-ts/setu-ts/tree/main/packages/exceptions",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/setu-ts/setu-ts/issues"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"type": "module",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"AGENT_CARD.md"
|
|
29
|
+
],
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@setu-ts/common": "^0.10.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@setu-ts/auth-plugin": "^0.10.0",
|
|
41
|
+
"@setu-ts/feature-flags-plugin": "^0.10.0",
|
|
42
|
+
"@setu-ts/http-security-plugin": "^0.10.0",
|
|
43
|
+
"@setu-ts/logger-plugin": "^0.10.0",
|
|
44
|
+
"@setu-ts/multi-tenancy-plugin": "^0.10.0",
|
|
45
|
+
"@setu-ts/runtime": "^0.10.0",
|
|
46
|
+
"@setu-ts/session-plugin": "^0.10.0",
|
|
47
|
+
"@setu-ts/storage-plugin": "^0.10.0",
|
|
48
|
+
"@setu-ts/testing": "^0.10.0",
|
|
49
|
+
"@types/node": "^24",
|
|
50
|
+
"typescript": "6.0.3",
|
|
51
|
+
"vitest": "5.0.3"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsc -b"
|
|
55
|
+
}
|
|
6
56
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exception factory functions.
|
|
3
|
+
*
|
|
4
|
+
* Each factory returns a {@linkcode HttpError} with a fixed `statusCode`,
|
|
5
|
+
* embodying **composition over inheritance** (AI_GUIDELINES §1.4,
|
|
6
|
+
* ARCHITECTURE.md §13). Throw one in a route handler or service and the
|
|
7
|
+
* error-handler middleware will format and send it with the right status.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { notFound, badRequest } from '@setu-ts/exceptions';
|
|
12
|
+
*
|
|
13
|
+
* function getUser(id: string) {
|
|
14
|
+
* if (!id) throw badRequest('id is required');
|
|
15
|
+
* const user = db.find(id);
|
|
16
|
+
* if (!user) throw notFound(`User ${id} not found`);
|
|
17
|
+
* return user;
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
import { HttpError } from './http-error.ts';
|
|
24
|
+
import type { ValidationError } from './http-error.ts';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Status-code → human title (single source of truth, shared with formatters)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A human-readable title for a given HTTP status code. This is the single
|
|
32
|
+
* source of truth used by both the factory functions and the Problem Details
|
|
33
|
+
* formatters so the `title` field never drifts from the produced `statusCode`.
|
|
34
|
+
*
|
|
35
|
+
* Keys are every status the framework itself produces — the ones the factories
|
|
36
|
+
* below return AND the ones first-party code brands onto an error with
|
|
37
|
+
* `withHttpStatusHint` from `@setu-ts/common`, which reaches this table through
|
|
38
|
+
* the responder seam without passing a factory (`413` from the request-size and
|
|
39
|
+
* body-cap refusals, `415` from the form accessor, `501`/`503`/`504` from the
|
|
40
|
+
* database, secrets and resilience refusals). A branded status with no row here
|
|
41
|
+
* is served the generic `'Error'` title, so `test/status-title-coverage.test.ts`
|
|
42
|
+
* enumerates the branded statuses from source and fails when one is missing —
|
|
43
|
+
* `415` reached a client as `"title": "Error"` under `'rfc9457'` while the same
|
|
44
|
+
* error read `"message": "Unsupported Media Type"` under `'default'`, because
|
|
45
|
+
* every test asserted the brand on the error object and none read the served
|
|
46
|
+
* body.
|
|
47
|
+
*
|
|
48
|
+
* @since 0.1.0
|
|
49
|
+
*/
|
|
50
|
+
export const STATUS_TITLES: Readonly<Record<number, string>> = {
|
|
51
|
+
400: 'Bad Request',
|
|
52
|
+
401: 'Unauthorized',
|
|
53
|
+
403: 'Forbidden',
|
|
54
|
+
404: 'Not Found',
|
|
55
|
+
409: 'Conflict',
|
|
56
|
+
413: 'Payload Too Large',
|
|
57
|
+
415: 'Unsupported Media Type',
|
|
58
|
+
422: 'Unprocessable Entity',
|
|
59
|
+
429: 'Too Many Requests',
|
|
60
|
+
500: 'Internal Server Error',
|
|
61
|
+
501: 'Not Implemented',
|
|
62
|
+
502: 'Bad Gateway',
|
|
63
|
+
503: 'Service Unavailable',
|
|
64
|
+
504: 'Gateway Timeout',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolves the human-readable title for a status code, falling back to a
|
|
69
|
+
* generic title for codes outside the well-known set.
|
|
70
|
+
*
|
|
71
|
+
* @param statusCode - The HTTP status code
|
|
72
|
+
* @returns A human-readable title
|
|
73
|
+
* @since 0.1.0
|
|
74
|
+
*/
|
|
75
|
+
export function statusTitle(statusCode: number): string {
|
|
76
|
+
return STATUS_TITLES[statusCode] ?? 'Error';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Factories
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates a `400 Bad Request` error.
|
|
85
|
+
*
|
|
86
|
+
* @param message - Human-readable error message
|
|
87
|
+
* @param details - Optional structured details appended to the error body
|
|
88
|
+
* @returns A `400` {@linkcode HttpError}
|
|
89
|
+
* @since 0.1.0
|
|
90
|
+
*/
|
|
91
|
+
export function badRequest(
|
|
92
|
+
message: string,
|
|
93
|
+
details?: Readonly<Record<string, unknown>>,
|
|
94
|
+
): HttpError {
|
|
95
|
+
return new HttpError(400, message, details);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates a `401 Unauthorized` error.
|
|
100
|
+
*
|
|
101
|
+
* @param message - Human-readable error message
|
|
102
|
+
* @returns A `401` {@linkcode HttpError}
|
|
103
|
+
* @since 0.1.0
|
|
104
|
+
*/
|
|
105
|
+
export function unauthorized(message: string): HttpError {
|
|
106
|
+
return new HttpError(401, message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Creates a `403 Forbidden` error.
|
|
111
|
+
*
|
|
112
|
+
* @param message - Human-readable error message
|
|
113
|
+
* @returns A `403` {@linkcode HttpError}
|
|
114
|
+
* @since 0.1.0
|
|
115
|
+
*/
|
|
116
|
+
export function forbidden(message: string): HttpError {
|
|
117
|
+
return new HttpError(403, message);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Creates a `404 Not Found` error.
|
|
122
|
+
*
|
|
123
|
+
* @param message - Human-readable error message
|
|
124
|
+
* @returns A `404` {@linkcode HttpError}
|
|
125
|
+
* @since 0.1.0
|
|
126
|
+
*/
|
|
127
|
+
export function notFound(message: string): HttpError {
|
|
128
|
+
return new HttpError(404, message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Creates a `409 Conflict` error.
|
|
133
|
+
*
|
|
134
|
+
* @param message - Human-readable error message
|
|
135
|
+
* @returns A `409` {@linkcode HttpError}
|
|
136
|
+
* @since 0.1.0
|
|
137
|
+
*/
|
|
138
|
+
export function conflict(message: string): HttpError {
|
|
139
|
+
return new HttpError(409, message);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Creates a `422 Unprocessable Entity` error wrapping a list of validation
|
|
144
|
+
* failures.
|
|
145
|
+
*
|
|
146
|
+
* The `errors` array is stored in the error's `details.errors` so it survives
|
|
147
|
+
* serialization into both the default and RFC 7807 error bodies.
|
|
148
|
+
*
|
|
149
|
+
* @param errors - The validation failures that caused this error
|
|
150
|
+
* @param message - Optional custom message (defaults to a count-based summary)
|
|
151
|
+
* @returns A `422` {@linkcode HttpError}
|
|
152
|
+
* @since 0.1.0
|
|
153
|
+
*/
|
|
154
|
+
export function validationError(errors: readonly ValidationError[], message?: string): HttpError {
|
|
155
|
+
const summary = message ?? `Validation failed with ${errors.length} error(s).`;
|
|
156
|
+
return new HttpError(422, summary, { errors });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates a `429 Too Many Requests` error.
|
|
161
|
+
*
|
|
162
|
+
* @param message - Human-readable error message
|
|
163
|
+
* @param details - Optional structured details (e.g. `retryAfter`)
|
|
164
|
+
* @returns A `429` {@linkcode HttpError}
|
|
165
|
+
* @since 0.1.0
|
|
166
|
+
*/
|
|
167
|
+
export function tooManyRequests(
|
|
168
|
+
message: string,
|
|
169
|
+
details?: Readonly<Record<string, unknown>>,
|
|
170
|
+
): HttpError {
|
|
171
|
+
return new HttpError(429, message, details);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Creates a `500 Internal Server Error` error.
|
|
176
|
+
*
|
|
177
|
+
* Pass the original error as `cause` so the root cause is preserved in the
|
|
178
|
+
* ES2022 error chain for logging and debugging.
|
|
179
|
+
*
|
|
180
|
+
* @param message - Human-readable error message
|
|
181
|
+
* @param cause - Optional underlying error forwarded to the cause chain
|
|
182
|
+
* @returns A `500` {@linkcode HttpError}
|
|
183
|
+
* @since 0.1.0
|
|
184
|
+
*/
|
|
185
|
+
export function internalServerError(message: string, cause?: Error): HttpError {
|
|
186
|
+
return new HttpError(500, message, undefined, cause);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Creates a `501 Not Implemented` error.
|
|
191
|
+
*
|
|
192
|
+
* @param message - Human-readable error message
|
|
193
|
+
* @returns A `501` {@linkcode HttpError}
|
|
194
|
+
* @since 0.1.0
|
|
195
|
+
*/
|
|
196
|
+
export function notImplemented(message: string): HttpError {
|
|
197
|
+
return new HttpError(501, message);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Creates a `503 Service Unavailable` error.
|
|
202
|
+
*
|
|
203
|
+
* @param message - Human-readable error message
|
|
204
|
+
* @returns A `503` {@linkcode HttpError}
|
|
205
|
+
* @since 0.1.0
|
|
206
|
+
*/
|
|
207
|
+
export function serviceUnavailable(message: string): HttpError {
|
|
208
|
+
return new HttpError(503, message);
|
|
209
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HttpError` — the framework's single concrete HTTP error type.
|
|
3
|
+
*
|
|
4
|
+
* Rather than a deep class hierarchy (`BadRequestError extends HttpError`,
|
|
5
|
+
* `NotFoundError extends HttpError`, …), the framework uses **composition via
|
|
6
|
+
* factory functions** (AI_GUIDELINES §1.4, ARCHITECTURE.md §13). Every HTTP
|
|
7
|
+
* error is an instance of this one class with a `statusCode` set at
|
|
8
|
+
* construction time by a factory. This keeps the hierarchy flat, makes errors
|
|
9
|
+
* trivially serializable, and allows runtime composition of properties.
|
|
10
|
+
*
|
|
11
|
+
* `cause` is forwarded to the ES2022 `Error` cause chain — pass the original
|
|
12
|
+
* error so callers can inspect the root cause without losing the stack.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single validation failure carried by a `422` error.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the shape used by `@setu-ts/validation-plugin` so error
|
|
21
|
+
* payloads are consistent across the validation and exceptions packages.
|
|
22
|
+
*
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export interface ValidationError {
|
|
26
|
+
/** Dot-path of the offending field (e.g. `"address.zip"`). */
|
|
27
|
+
readonly field: string;
|
|
28
|
+
/** Human-readable description of the failure. */
|
|
29
|
+
readonly message: string;
|
|
30
|
+
/** Optional machine-readable failure code. */
|
|
31
|
+
readonly code?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Options accepted by the {@linkcode HttpError} constructor.
|
|
36
|
+
*
|
|
37
|
+
* Extracted as a type so factory functions can accept a consistent object.
|
|
38
|
+
*
|
|
39
|
+
* @since 0.1.0
|
|
40
|
+
*/
|
|
41
|
+
export interface HttpErrorInit {
|
|
42
|
+
/** HTTP status code (e.g. `404`). */
|
|
43
|
+
readonly statusCode: number;
|
|
44
|
+
/** Human-readable error message. */
|
|
45
|
+
readonly message: string;
|
|
46
|
+
/** Optional structured details attached to the error body. */
|
|
47
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
48
|
+
/** Optional underlying cause (forwarded to the ES2022 `Error` cause chain). */
|
|
49
|
+
readonly cause?: Error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The framework's HTTP error type.
|
|
54
|
+
*
|
|
55
|
+
* Throw an instance directly, or — preferably — construct one via a factory
|
|
56
|
+
* function from {@linkcode ./exceptions.ts} so the status code is correct by
|
|
57
|
+
* construction:
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* import { notFound } from '@setu-ts/exceptions';
|
|
62
|
+
*
|
|
63
|
+
* throw notFound(`User ${id} not found`);
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* The error handler middleware inspects the `statusCode` to set the response
|
|
67
|
+
* status, and serializes `message` and `details` into the error body.
|
|
68
|
+
*
|
|
69
|
+
* @since 0.1.0
|
|
70
|
+
*/
|
|
71
|
+
export class HttpError extends Error {
|
|
72
|
+
/** The HTTP status code this error maps to. */
|
|
73
|
+
readonly statusCode: number;
|
|
74
|
+
/**
|
|
75
|
+
* Structured details appended to the error body. Omitted entirely when not
|
|
76
|
+
* supplied (never `undefined`) so serialization stays clean.
|
|
77
|
+
*
|
|
78
|
+
* `declare` suppresses the ES2022 class-field initialization that would
|
|
79
|
+
* otherwise set this to `undefined` on every instance — it only exists on
|
|
80
|
+
* the object when actually assigned by the constructor.
|
|
81
|
+
*/
|
|
82
|
+
declare readonly details?: Readonly<Record<string, unknown>>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Creates a new `HttpError`.
|
|
86
|
+
*
|
|
87
|
+
* Prefer the factory functions in `exceptions.ts` over calling this
|
|
88
|
+
* constructor directly — they guarantee a correct status code.
|
|
89
|
+
*
|
|
90
|
+
* @param statusCode - HTTP status code (e.g. `404`)
|
|
91
|
+
* @param message - Human-readable error message
|
|
92
|
+
* @param details - Optional structured details; omitted when absent
|
|
93
|
+
* @param cause - Optional underlying error; forwarded to the ES2022 cause chain
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```typescript
|
|
97
|
+
* const err = new HttpError(404, 'User not found');
|
|
98
|
+
* console.log(err.statusCode); // 404
|
|
99
|
+
* ```
|
|
100
|
+
* @since 0.1.0
|
|
101
|
+
*/
|
|
102
|
+
constructor(
|
|
103
|
+
statusCode: number,
|
|
104
|
+
message: string,
|
|
105
|
+
details?: Readonly<Record<string, unknown>>,
|
|
106
|
+
cause?: Error,
|
|
107
|
+
) {
|
|
108
|
+
// Forward `cause` to the ES2022 Error cause chain when present. Under
|
|
109
|
+
// `exactOptionalPropertyTypes`, passing `{ cause: undefined }` would
|
|
110
|
+
// violate the option, so the options object is built conditionally.
|
|
111
|
+
super(message, cause !== undefined ? { cause } : undefined);
|
|
112
|
+
this.name = 'HttpError';
|
|
113
|
+
this.statusCode = statusCode;
|
|
114
|
+
if (details !== undefined) {
|
|
115
|
+
this.details = details;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates an `HttpError` from an {@linkcode HttpErrorInit} object.
|
|
121
|
+
*
|
|
122
|
+
* Convenience overload used by factory functions so they can branch on
|
|
123
|
+
* optional `details`/`cause` once.
|
|
124
|
+
*
|
|
125
|
+
* @param init - The error options
|
|
126
|
+
* @returns A new `HttpError`
|
|
127
|
+
* @since 0.1.0
|
|
128
|
+
*/
|
|
129
|
+
static from(init: HttpErrorInit): HttpError {
|
|
130
|
+
return new HttpError(init.statusCode, init.message, init.details, init.cause);
|
|
131
|
+
}
|
|
132
|
+
}
|