@setu-ts/exceptions 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +180 -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 +112 -0
- package/dist/middleware/error-handler.d.ts.map +1 -0
- package/dist/middleware/error-handler.js +210 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/middleware/error-responder-impl.d.ts +52 -0
- package/dist/middleware/error-responder-impl.d.ts.map +1 -0
- package/dist/middleware/error-responder-impl.js +148 -0
- package/dist/middleware/error-responder-impl.js.map +1 -0
- package/package.json +52 -3
- package/src/errors/exceptions.ts +212 -0
- package/src/errors/http-error.ts +132 -0
- package/src/formatters/error-formatter.ts +127 -0
- package/src/formatters/problem-details.ts +176 -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 +337 -0
- package/src/middleware/error-responder-impl.ts +178 -0
|
@@ -0,0 +1,212 @@
|
|
|
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(
|
|
155
|
+
errors: readonly ValidationError[],
|
|
156
|
+
message?: string,
|
|
157
|
+
): HttpError {
|
|
158
|
+
const summary = message ?? `Validation failed with ${errors.length} error(s).`;
|
|
159
|
+
return new HttpError(422, summary, { errors });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Creates a `429 Too Many Requests` error.
|
|
164
|
+
*
|
|
165
|
+
* @param message - Human-readable error message
|
|
166
|
+
* @param details - Optional structured details (e.g. `retryAfter`)
|
|
167
|
+
* @returns A `429` {@linkcode HttpError}
|
|
168
|
+
* @since 0.1.0
|
|
169
|
+
*/
|
|
170
|
+
export function tooManyRequests(
|
|
171
|
+
message: string,
|
|
172
|
+
details?: Readonly<Record<string, unknown>>,
|
|
173
|
+
): HttpError {
|
|
174
|
+
return new HttpError(429, message, details);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Creates a `500 Internal Server Error` error.
|
|
179
|
+
*
|
|
180
|
+
* Pass the original error as `cause` so the root cause is preserved in the
|
|
181
|
+
* ES2022 error chain for logging and debugging.
|
|
182
|
+
*
|
|
183
|
+
* @param message - Human-readable error message
|
|
184
|
+
* @param cause - Optional underlying error forwarded to the cause chain
|
|
185
|
+
* @returns A `500` {@linkcode HttpError}
|
|
186
|
+
* @since 0.1.0
|
|
187
|
+
*/
|
|
188
|
+
export function internalServerError(message: string, cause?: Error): HttpError {
|
|
189
|
+
return new HttpError(500, message, undefined, cause);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Creates a `501 Not Implemented` error.
|
|
194
|
+
*
|
|
195
|
+
* @param message - Human-readable error message
|
|
196
|
+
* @returns A `501` {@linkcode HttpError}
|
|
197
|
+
* @since 0.1.0
|
|
198
|
+
*/
|
|
199
|
+
export function notImplemented(message: string): HttpError {
|
|
200
|
+
return new HttpError(501, message);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Creates a `503 Service Unavailable` error.
|
|
205
|
+
*
|
|
206
|
+
* @param message - Human-readable error message
|
|
207
|
+
* @returns A `503` {@linkcode HttpError}
|
|
208
|
+
* @since 0.1.0
|
|
209
|
+
*/
|
|
210
|
+
export function serviceUnavailable(message: string): HttpError {
|
|
211
|
+
return new HttpError(503, message);
|
|
212
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error format selector — resolves the configured error format to a concrete
|
|
3
|
+
* formatter function.
|
|
4
|
+
*
|
|
5
|
+
* Supported formats:
|
|
6
|
+
* - `'default'` — Framework-standard error shape (`statusCode`, `message`,
|
|
7
|
+
* optional `details`).
|
|
8
|
+
* - `'rfc9457'` — RFC 9457 Problem Details (see `rfc9457-formatter.ts`).
|
|
9
|
+
* - `'rfc7807'` — deprecated; RFC 7807 Problem Details (see
|
|
10
|
+
* `rfc7807-formatter.ts`). RFC 7807 was obsoleted by RFC 9457.
|
|
11
|
+
* - A custom function — used as-is.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import type { IRequestContext } from '@setu-ts/common';
|
|
16
|
+
|
|
17
|
+
import { rfc9457Formatter } from './rfc9457-formatter.ts';
|
|
18
|
+
import { rfc7807Formatter } from './rfc7807-formatter.ts';
|
|
19
|
+
import { HttpError } from '../errors/http-error.ts';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A function that formats a thrown error into a serializable error body.
|
|
27
|
+
*
|
|
28
|
+
* @param error - The thrown error (an `HttpError` or a generic `Error`)
|
|
29
|
+
* @param ctx - Optional request context (used for `instance`, logging, etc.)
|
|
30
|
+
* @returns The formatted error body
|
|
31
|
+
* @since 0.1.0
|
|
32
|
+
*/
|
|
33
|
+
export type ErrorHandlerFormatter = (
|
|
34
|
+
error: Error,
|
|
35
|
+
ctx?: IRequestContext,
|
|
36
|
+
) => Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The built-in error format identifiers for `@setu-ts/exceptions`.
|
|
40
|
+
*
|
|
41
|
+
* `'rfc7807'` is retained for backward compatibility and resolves to the
|
|
42
|
+
* deprecated {@linkcode rfc7807Formatter}; prefer `'rfc9457'`.
|
|
43
|
+
*
|
|
44
|
+
* @since 0.1.0
|
|
45
|
+
*/
|
|
46
|
+
export type ErrorFormat = 'default' | 'rfc9457' | 'rfc7807';
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Built-in default formatter
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The framework-standard error body shape.
|
|
54
|
+
*
|
|
55
|
+
* @since 0.1.0
|
|
56
|
+
*/
|
|
57
|
+
export interface DefaultErrorBody {
|
|
58
|
+
/** The HTTP status code. */
|
|
59
|
+
readonly statusCode: number;
|
|
60
|
+
/** Human-readable error message. */
|
|
61
|
+
readonly message: string;
|
|
62
|
+
/** Optional structured details (present when the error carries any). */
|
|
63
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
64
|
+
/** Optional stack trace (present only when `includeStackTrace` is on). */
|
|
65
|
+
readonly stack?: string;
|
|
66
|
+
/** Allow callers to attach further members. */
|
|
67
|
+
readonly [key: string]: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Framework-standard error formatter.
|
|
72
|
+
*
|
|
73
|
+
* Returns a body shaped as:
|
|
74
|
+
*
|
|
75
|
+
* ```json
|
|
76
|
+
* { "statusCode": 404, "message": "User not found", "details": { ... } }
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* Generic (non-`HttpError`) errors map to `500`.
|
|
80
|
+
*
|
|
81
|
+
* @param error - The thrown error to format
|
|
82
|
+
* @returns The default formatted error body
|
|
83
|
+
* @since 0.1.0
|
|
84
|
+
*/
|
|
85
|
+
export const defaultFormatter: ErrorHandlerFormatter = (error: Error): DefaultErrorBody => {
|
|
86
|
+
const isHttp = error instanceof HttpError;
|
|
87
|
+
return {
|
|
88
|
+
statusCode: isHttp ? error.statusCode : 500,
|
|
89
|
+
message: error.message,
|
|
90
|
+
...(isHttp && error.details !== undefined && { details: error.details }),
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Selector
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the error format configuration to a concrete formatter function.
|
|
100
|
+
*
|
|
101
|
+
* - When `format` is `'default'`, `'rfc9457'`, or the deprecated `'rfc7807'`,
|
|
102
|
+
* the corresponding built-in formatter is returned.
|
|
103
|
+
* - When `format` is already a function, it is returned as-is.
|
|
104
|
+
* - Otherwise a `TypeError` is thrown.
|
|
105
|
+
*
|
|
106
|
+
* @param format - The error format identifier or custom formatter function
|
|
107
|
+
* @returns The resolved formatter function
|
|
108
|
+
* @throws {TypeError} When an unknown format string is provided
|
|
109
|
+
* @since 0.1.0
|
|
110
|
+
*/
|
|
111
|
+
export function selectFormatter(
|
|
112
|
+
format: ErrorFormat | ErrorHandlerFormatter = 'default',
|
|
113
|
+
): ErrorHandlerFormatter {
|
|
114
|
+
if (typeof format === 'function') {
|
|
115
|
+
return format;
|
|
116
|
+
}
|
|
117
|
+
switch (format) {
|
|
118
|
+
case 'default':
|
|
119
|
+
return defaultFormatter;
|
|
120
|
+
case 'rfc9457':
|
|
121
|
+
return rfc9457Formatter;
|
|
122
|
+
case 'rfc7807':
|
|
123
|
+
return rfc7807Formatter;
|
|
124
|
+
default:
|
|
125
|
+
throw new TypeError(`Unknown error format: "${format}"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Problem Details assembly.
|
|
3
|
+
*
|
|
4
|
+
* Both the RFC 9457 formatter and the deprecated RFC 7807 one produce the same
|
|
5
|
+
* body apart from the `type` member, so the assembly lives here once and each
|
|
6
|
+
* formatter supplies only its own {@linkcode ProblemTypeResolver}
|
|
7
|
+
* (AI_GUIDELINES §11.1).
|
|
8
|
+
*
|
|
9
|
+
* This module is internal: `buildProblemDetails` and `ProblemTypeResolver` are
|
|
10
|
+
* not exported from `src/index.ts`. `ERROR_TYPE_BASE` and `ProblemDetails` are
|
|
11
|
+
* re-exported publicly by `rfc9457-formatter.ts`.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import type { IRequestContext } from '@setu-ts/common';
|
|
16
|
+
|
|
17
|
+
import { statusTitle } from '../errors/exceptions.ts';
|
|
18
|
+
import { HttpError } from '../errors/http-error.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The canonical base URI for framework-produced problem type identifiers.
|
|
22
|
+
*
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export const ERROR_TYPE_BASE = 'https://setu-ts.dev/errors';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The problem type URI identifying a validation failure.
|
|
29
|
+
*
|
|
30
|
+
* Spelled to match the literal `@setu-ts/validation-plugin` emits, so both
|
|
31
|
+
* packages identify the same problem type with the same URI. The two cannot
|
|
32
|
+
* share a constant — a package may not import another package's internals
|
|
33
|
+
* (AI_GUIDELINES §2.2) — so the agreement is pinned by a test in each.
|
|
34
|
+
*
|
|
35
|
+
* @since 0.1.0
|
|
36
|
+
*/
|
|
37
|
+
export const VALIDATION_TYPE: string = `${ERROR_TYPE_BASE}/validation`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The RFC 9457 default problem type, meaning "no semantics beyond the HTTP
|
|
41
|
+
* status code" (RFC 9457 §4.2).
|
|
42
|
+
*
|
|
43
|
+
* @since 0.1.0
|
|
44
|
+
*/
|
|
45
|
+
export const ABOUT_BLANK = 'about:blank';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A Problem Details object as defined by
|
|
49
|
+
* [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html).
|
|
50
|
+
*
|
|
51
|
+
* Extension members beyond the five core fields are allowed (RFC 9457 §3.2),
|
|
52
|
+
* so `errors` and `stack` may be present.
|
|
53
|
+
*
|
|
54
|
+
* @since 0.1.0
|
|
55
|
+
*/
|
|
56
|
+
export interface ProblemDetails {
|
|
57
|
+
/** A URI reference identifying the problem type. */
|
|
58
|
+
readonly type: string;
|
|
59
|
+
/** A short, human-readable summary of the problem type. */
|
|
60
|
+
readonly title: string;
|
|
61
|
+
/** The HTTP status code generated for this occurrence. */
|
|
62
|
+
readonly status: number;
|
|
63
|
+
/** A human-readable explanation specific to this occurrence. */
|
|
64
|
+
readonly detail: string;
|
|
65
|
+
/** A URI reference identifying the specific occurrence (request path). */
|
|
66
|
+
readonly instance?: string;
|
|
67
|
+
/** Optional validation failures extension (present for `422` errors). */
|
|
68
|
+
readonly errors?: ReadonlyArray<{
|
|
69
|
+
field: string;
|
|
70
|
+
message: string;
|
|
71
|
+
code?: string;
|
|
72
|
+
}>;
|
|
73
|
+
/** Optional stack trace (present only when `includeStackTrace` is on). */
|
|
74
|
+
readonly stack?: string;
|
|
75
|
+
/** Allow callers to attach further extension members. */
|
|
76
|
+
readonly [key: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Marks an {@linkcode HttpError} as one the M70f error responder built, and
|
|
81
|
+
* carries the calling site's disclosure.
|
|
82
|
+
*
|
|
83
|
+
* The responder must hand the disclosure to the Problem Details formatters as
|
|
84
|
+
* the `detail` member while leaving the site's title as the error `message`
|
|
85
|
+
* (which the `default` formatter emits verbatim). It cannot use
|
|
86
|
+
* `HttpError.details` alone as that channel: `details` is a free-form bag an
|
|
87
|
+
* application writes into too, so promoting any `details.detail` would change
|
|
88
|
+
* the body of a thrown error that merely uses the same key name.
|
|
89
|
+
*
|
|
90
|
+
* This marker is a module-private `Symbol` — not `Symbol.for` — because both
|
|
91
|
+
* writer ({@linkcode ../middleware/error-responder-impl.ts}) and reader
|
|
92
|
+
* ({@linkcode buildProblemDetails}) live in this package and import it
|
|
93
|
+
* directly; a global registry key would expose an internal channel that a
|
|
94
|
+
* third party could forge.
|
|
95
|
+
*
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
export const RESPONDER_DETAIL: unique symbol = Symbol('setu.exceptions.responder-detail');
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolves the `type` member of a Problem Details body.
|
|
102
|
+
*
|
|
103
|
+
* @param statusCode - The HTTP status code of the occurrence
|
|
104
|
+
* @param hasErrors - Whether the error carries a validation `errors` extension
|
|
105
|
+
* @returns The problem type URI
|
|
106
|
+
* @since 0.1.0
|
|
107
|
+
*/
|
|
108
|
+
export type ProblemTypeResolver = (statusCode: number, hasErrors: boolean) => string;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extracts the validation `errors` extension from an error's `details`, if any.
|
|
112
|
+
*
|
|
113
|
+
* @param error - The HTTP error to read
|
|
114
|
+
* @returns The validation failures, or `undefined` when the error carries none
|
|
115
|
+
*/
|
|
116
|
+
function extractErrors(error: HttpError): ProblemDetails['errors'] {
|
|
117
|
+
const details = error.details;
|
|
118
|
+
if (details === undefined || !('errors' in details)) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return details.errors as ProblemDetails['errors'];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Assembles a Problem Details body from a thrown error.
|
|
126
|
+
*
|
|
127
|
+
* - `status` and `detail` come from the {@linkcode HttpError} (or default to
|
|
128
|
+
* `500` / the error message for generic `Error`s).
|
|
129
|
+
* - `title` is derived from the status code via the shared status-title map.
|
|
130
|
+
* - `instance` is the request path when a context is supplied.
|
|
131
|
+
* - `errors` is included when the error carries validation details.
|
|
132
|
+
* - `type` is delegated to `resolveType`, the only member the two supported
|
|
133
|
+
* specification versions disagree on.
|
|
134
|
+
*
|
|
135
|
+
* @param error - The thrown error to format
|
|
136
|
+
* @param ctx - Optional request context (used for `instance`)
|
|
137
|
+
* @param resolveType - Strategy supplying the `type` member
|
|
138
|
+
* @returns A Problem Details body
|
|
139
|
+
* @since 0.1.0
|
|
140
|
+
*/
|
|
141
|
+
export function buildProblemDetails(
|
|
142
|
+
error: Error,
|
|
143
|
+
ctx: IRequestContext | undefined,
|
|
144
|
+
resolveType: ProblemTypeResolver,
|
|
145
|
+
): ProblemDetails {
|
|
146
|
+
const isHttp = error instanceof HttpError;
|
|
147
|
+
const statusCode = isHttp ? error.statusCode : 500;
|
|
148
|
+
const errors = isHttp ? extractErrors(error) : undefined;
|
|
149
|
+
// A responder-built error carries the site's disclosure in its structured
|
|
150
|
+
// details under a `detail` key (M70f); the Problem Details `detail` member
|
|
151
|
+
// is that disclosure, falling back to the error message (the title) when a
|
|
152
|
+
// site supplied none.
|
|
153
|
+
//
|
|
154
|
+
// The promotion is gated on {@linkcode RESPONDER_DETAIL}, NOT on the mere
|
|
155
|
+
// presence of a `detail` key, because `HttpError.details` is a free-form bag
|
|
156
|
+
// an application also writes into: reading any `details.detail` would
|
|
157
|
+
// silently change the `detail` member of a thrown
|
|
158
|
+
// `new HttpError(400, 'Invalid payload', { detail: 'code-7' })` from its
|
|
159
|
+
// message to `'code-7'` — a released-behaviour change to every application
|
|
160
|
+
// that happens to use that key name (M70f code review, finding 4). Only an
|
|
161
|
+
// error the responder built carries the marker, so a thrown error keeps the
|
|
162
|
+
// pre-M70f shape: `detail` is the message.
|
|
163
|
+
const carriedDetail = isHttp && RESPONDER_DETAIL in error
|
|
164
|
+
? (error as HttpError & { [RESPONDER_DETAIL]?: unknown })[RESPONDER_DETAIL]
|
|
165
|
+
: undefined;
|
|
166
|
+
const detail = typeof carriedDetail === 'string' ? carriedDetail : error.message;
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
type: resolveType(statusCode, errors !== undefined),
|
|
170
|
+
title: statusTitle(statusCode),
|
|
171
|
+
status: statusCode,
|
|
172
|
+
detail,
|
|
173
|
+
...(ctx !== undefined && { instance: ctx.request.path }),
|
|
174
|
+
...(errors !== undefined && { errors }),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 7807 Problem Details error formatter (deprecated).
|
|
3
|
+
*
|
|
4
|
+
* RFC 7807 was obsoleted by [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html)
|
|
5
|
+
* in July 2023. This formatter is retained unchanged so existing callers keep
|
|
6
|
+
* their exact body shape through a deprecation period (AI_GUIDELINES §9.2); new
|
|
7
|
+
* code should use `rfc9457-formatter.ts`.
|
|
8
|
+
*
|
|
9
|
+
* It differs from the RFC 9457 formatter in the `type` member alone: this one
|
|
10
|
+
* mints a URI from the status code for every error, which RFC 9457 §4.2 replaces
|
|
11
|
+
* with `about:blank` for problems carrying no semantics beyond that status.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import type { IRequestContext } from '@setu-ts/common';
|
|
16
|
+
|
|
17
|
+
import type { ErrorHandlerFormatter } from './error-formatter.ts';
|
|
18
|
+
import { buildProblemDetails, ERROR_TYPE_BASE, type ProblemDetails } from './problem-details.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves the RFC 7807 `type` member: a URI minted from the status code.
|
|
22
|
+
*
|
|
23
|
+
* @param statusCode - The HTTP status code of the occurrence
|
|
24
|
+
* @returns The status-derived problem type URI
|
|
25
|
+
*/
|
|
26
|
+
const resolveRfc7807Type = (statusCode: number): string => `${ERROR_TYPE_BASE}/${statusCode}`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format an error as RFC 7807 Problem Details.
|
|
30
|
+
*
|
|
31
|
+
* @param error - The thrown error to format
|
|
32
|
+
* @param ctx - Optional request context (used for `instance`)
|
|
33
|
+
* @returns An RFC 7807 Problem Details body
|
|
34
|
+
* @deprecated RFC 7807 was obsoleted by RFC 9457. Use `rfc9457Formatter`
|
|
35
|
+
* instead, or the `'rfc9457'` format alias. Will be removed in v1.0.0.
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* // Before
|
|
39
|
+
* app.middleware.add(errorHandler({ format: 'rfc7807' }));
|
|
40
|
+
* // After — `type` becomes 'about:blank' for status-only problems
|
|
41
|
+
* app.middleware.add(errorHandler({ format: 'rfc9457' }));
|
|
42
|
+
* ```
|
|
43
|
+
* @since 0.1.0
|
|
44
|
+
*/
|
|
45
|
+
export const rfc7807Formatter: ErrorHandlerFormatter = (
|
|
46
|
+
error: Error,
|
|
47
|
+
ctx?: IRequestContext,
|
|
48
|
+
): ProblemDetails => buildProblemDetails(error, ctx, resolveRfc7807Type);
|