@camcima/nestjs-rfc9457 0.0.2
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 +753 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/problem-details.factory.d.ts +18 -0
- package/dist/problem-details.factory.js +242 -0
- package/dist/problem-details.factory.js.map +1 -0
- package/dist/problem-type.decorator.d.ts +3 -0
- package/dist/problem-type.decorator.js +11 -0
- package/dist/problem-type.decorator.js.map +1 -0
- package/dist/rfc9457.constants.d.ts +3 -0
- package/dist/rfc9457.constants.js +7 -0
- package/dist/rfc9457.constants.js.map +1 -0
- package/dist/rfc9457.exception-filter.d.ts +11 -0
- package/dist/rfc9457.exception-filter.js +65 -0
- package/dist/rfc9457.exception-filter.js.map +1 -0
- package/dist/rfc9457.interfaces.d.ts +37 -0
- package/dist/rfc9457.interfaces.js +3 -0
- package/dist/rfc9457.interfaces.js.map +1 -0
- package/dist/rfc9457.module.d.ts +7 -0
- package/dist/rfc9457.module.js +93 -0
- package/dist/rfc9457.module.js.map +1 -0
- package/dist/utils/slug.d.ts +1 -0
- package/dist/utils/slug.js +13 -0
- package/dist/utils/slug.js.map +1 -0
- package/dist/validation/rfc9457-validation-pipe-exception.factory.d.ts +2 -0
- package/dist/validation/rfc9457-validation-pipe-exception.factory.js +10 -0
- package/dist/validation/rfc9457-validation-pipe-exception.factory.js.map +1 -0
- package/dist/validation/rfc9457-validation.exception.d.ts +5 -0
- package/dist/validation/rfc9457-validation.exception.js +12 -0
- package/dist/validation/rfc9457-validation.exception.js.map +1 -0
- package/package.json +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<picture>
|
|
4
|
+
<img alt="nestjs-rfc9457" src="assets/logo.svg" width="580">
|
|
5
|
+
</picture>
|
|
6
|
+
|
|
7
|
+
<br>
|
|
8
|
+
|
|
9
|
+
[](https://github.com/camcima/nestjs-rfc9457/actions/workflows/ci.yml)
|
|
10
|
+
[](https://codecov.io/gh/camcima/nestjs-rfc9457)
|
|
11
|
+
[](https://www.npmjs.com/package/@camcima/nestjs-rfc9457)
|
|
12
|
+
[](https://www.npmjs.com/package/@camcima/nestjs-rfc9457)
|
|
13
|
+
[](https://opensource.org/licenses/MIT)
|
|
14
|
+
[](https://www.typescriptlang.org/)
|
|
15
|
+
[](https://nodejs.org/)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
NestJS library for [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details HTTP error responses.
|
|
20
|
+
|
|
21
|
+
## What is RFC 9457?
|
|
22
|
+
|
|
23
|
+
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) (July 2023) defines a standard JSON format for HTTP API error responses, using the `application/problem+json` media type. It supersedes RFC 7807 and gives APIs a consistent, machine-readable way to communicate errors.
|
|
24
|
+
|
|
25
|
+
A Problem Details response looks like this:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"type": "https://api.example.com/problems/not-found",
|
|
30
|
+
"title": "Not Found",
|
|
31
|
+
"status": 404,
|
|
32
|
+
"detail": "User 42 not found",
|
|
33
|
+
"instance": "/api/users/42"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The five standard members are:
|
|
38
|
+
|
|
39
|
+
| Member | Description |
|
|
40
|
+
| ---------- | ------------------------------------------------------ |
|
|
41
|
+
| `type` | URI identifying the problem type |
|
|
42
|
+
| `title` | Short human-readable summary of the problem type |
|
|
43
|
+
| `status` | HTTP status code (advisory) |
|
|
44
|
+
| `detail` | Human-readable explanation of this specific occurrence |
|
|
45
|
+
| `instance` | URI identifying this specific occurrence |
|
|
46
|
+
|
|
47
|
+
Extension members (arbitrary key-value pairs) are allowed for problem-type-specific data.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- Zero-config drop-in: import the module once in `AppModule` and all HTTP exceptions become RFC 9457 responses
|
|
54
|
+
- Automatic `ValidationPipe` integration — flat string-array errors work out of the box (Tier 1)
|
|
55
|
+
- Enhanced structured validation errors with `property`, `constraints`, and nested `children` (Tier 2)
|
|
56
|
+
- `@ProblemType()` class decorator for custom exception types with full prototype-chain inheritance
|
|
57
|
+
- Configurable `type` URI generation with `typeBaseUri` and automatic kebab-case slug derivation
|
|
58
|
+
- Four `instance` strategies: `'request-uri'`, `'uuid'`, `'none'`, or a custom callback
|
|
59
|
+
- Optional catch-all mode for non-`HttpException` throwables (produces 500 Problem Details)
|
|
60
|
+
- Custom `exceptionMapper` callback for full control over any exception
|
|
61
|
+
- `ProblemDetailsFactory` is injectable — use it directly in GraphQL, microservices, or custom filters
|
|
62
|
+
- Works with both Express and Fastify adapters
|
|
63
|
+
- Zero runtime dependencies; `class-validator` is an optional peer dependency
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install @camcima/nestjs-rfc9457
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
yarn add @camcima/nestjs-rfc9457
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pnpm add @camcima/nestjs-rfc9457
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Peer dependencies
|
|
82
|
+
|
|
83
|
+
| Package | Version | Required |
|
|
84
|
+
| ------------------ | ---------------------- | ------------------------------------ |
|
|
85
|
+
| `@nestjs/common` | `^10.0.0 \|\| ^11.0.0` | Yes |
|
|
86
|
+
| `@nestjs/core` | `^10.0.0 \|\| ^11.0.0` | Yes |
|
|
87
|
+
| `reflect-metadata` | `^0.1.13 \|\| ^0.2.0` | Yes |
|
|
88
|
+
| `class-validator` | `^0.14.0` | No (optional, for Tier 2 validation) |
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Quick Start
|
|
93
|
+
|
|
94
|
+
Import `Rfc9457Module` once in your root `AppModule`. Because the module is **global**, you do not need to import it in any other module — the exception filter applies everywhere in your application automatically.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// app.module.ts
|
|
98
|
+
import { Module } from '@nestjs/common';
|
|
99
|
+
import { Rfc9457Module } from '@camcima/nestjs-rfc9457';
|
|
100
|
+
|
|
101
|
+
@Module({
|
|
102
|
+
imports: [Rfc9457Module.forRoot()],
|
|
103
|
+
})
|
|
104
|
+
export class AppModule {}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That is all the configuration you need. Every `HttpException` thrown anywhere in your application will now produce an RFC 9457 response.
|
|
108
|
+
|
|
109
|
+
### Before and after
|
|
110
|
+
|
|
111
|
+
**Before** (standard NestJS `NotFoundException`):
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"statusCode": 404,
|
|
116
|
+
"message": "User 42 not found",
|
|
117
|
+
"error": "Not Found"
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**After** (with `@camcima/nestjs-rfc9457`):
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"type": "about:blank",
|
|
126
|
+
"title": "Not Found",
|
|
127
|
+
"status": 404,
|
|
128
|
+
"detail": "User 42 not found"
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The response `Content-Type` is set to `application/problem+json` as required by the RFC.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
`Rfc9457Module.forRoot()` accepts an optional `Rfc9457ModuleOptions` object.
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
Rfc9457Module.forRoot({
|
|
142
|
+
typeBaseUri: 'https://api.example.com/problems',
|
|
143
|
+
instanceStrategy: 'request-uri',
|
|
144
|
+
catchAllExceptions: true,
|
|
145
|
+
exceptionMapper: (exception, request) => {
|
|
146
|
+
/* ... */
|
|
147
|
+
},
|
|
148
|
+
validationExceptionMapper: (messages, request) => {
|
|
149
|
+
/* ... */
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `typeBaseUri`
|
|
155
|
+
|
|
156
|
+
**Type**: `string` | **Default**: `undefined`
|
|
157
|
+
|
|
158
|
+
When set, the library generates `type` URIs by combining the base URI with a kebab-case slug derived from the HTTP status phrase. When omitted, `type` defaults to `"about:blank"` (per RFC 9457 §4.2).
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
Rfc9457Module.forRoot({
|
|
162
|
+
typeBaseUri: 'https://api.example.com/problems',
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A `NotFoundException` (404) becomes:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"type": "https://api.example.com/problems/not-found",
|
|
171
|
+
"title": "Not Found",
|
|
172
|
+
"status": 404
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Slug derivation uses the HTTP status phrase from Node's built-in `http.STATUS_CODES`:
|
|
177
|
+
|
|
178
|
+
- `"Not Found"` → `not-found`
|
|
179
|
+
- `"Internal Server Error"` → `internal-server-error`
|
|
180
|
+
- `"Unprocessable Entity"` → `unprocessable-entity`
|
|
181
|
+
|
|
182
|
+
### `instanceStrategy`
|
|
183
|
+
|
|
184
|
+
**Type**: `'request-uri' | 'uuid' | 'none' | ((request, exception) => string | undefined)` | **Default**: `'none'`
|
|
185
|
+
|
|
186
|
+
Controls how the `instance` field is populated.
|
|
187
|
+
|
|
188
|
+
**`'none'`** — `instance` is omitted from the response (default):
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
Rfc9457Module.forRoot({ instanceStrategy: 'none' });
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**`'request-uri'`** — uses the request URL path:
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
Rfc9457Module.forRoot({ instanceStrategy: 'request-uri' });
|
|
198
|
+
// instance: "/api/users/42"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**`'uuid'`** — generates a `urn:uuid:<v4>` per occurrence:
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
Rfc9457Module.forRoot({ instanceStrategy: 'uuid' });
|
|
205
|
+
// instance: "urn:uuid:a8098c1a-f86e-11da-bd1a-00112444be1e"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Custom callback** — full control, receives the request and the original exception:
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
Rfc9457Module.forRoot({
|
|
212
|
+
instanceStrategy: (request, exception) => {
|
|
213
|
+
return `https://errors.example.com/log?path=${request.url}`;
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Return `undefined` from a custom callback to omit `instance` for that occurrence.
|
|
219
|
+
|
|
220
|
+
The `request` parameter implements `Rfc9457Request`:
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
interface Rfc9457Request {
|
|
224
|
+
url: string;
|
|
225
|
+
method: string;
|
|
226
|
+
[key: string]: unknown;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Both Express and Fastify request objects satisfy this interface.
|
|
231
|
+
|
|
232
|
+
### `catchAllExceptions`
|
|
233
|
+
|
|
234
|
+
**Type**: `boolean` | **Default**: `false`
|
|
235
|
+
|
|
236
|
+
When `false` (default), exceptions that are not `HttpException` instances are passed to NestJS's default error handling via `super.catch()`. When `true`, any throwable — including plain `Error` objects and non-HTTP exceptions — is caught and produces a generic 500 Problem Details response. Internal error information is never exposed in the response body.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
Rfc9457Module.forRoot({ catchAllExceptions: true });
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### `exceptionMapper`
|
|
243
|
+
|
|
244
|
+
**Type**: `(exception: unknown, request: Rfc9457Request) => ProblemDetail | null`
|
|
245
|
+
|
|
246
|
+
A callback that runs first in the resolution chain. Return a `ProblemDetail` object to take full control of the response, or `null` to fall through to the next resolution step (`@ProblemType()` metadata, then validation handling, then default mapping).
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
Rfc9457Module.forRoot({
|
|
250
|
+
exceptionMapper: (exception, request) => {
|
|
251
|
+
if (exception instanceof DatabaseException) {
|
|
252
|
+
return {
|
|
253
|
+
type: 'https://api.example.com/problems/database-error',
|
|
254
|
+
title: 'Database Error',
|
|
255
|
+
status: 503,
|
|
256
|
+
detail: 'A temporary database error occurred',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return null; // fall through to default handling
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
If the returned `ProblemDetail` omits `status`, the factory falls back to `exception.getStatus()` (if it is an `HttpException`) or `500`.
|
|
265
|
+
|
|
266
|
+
### `validationExceptionMapper`
|
|
267
|
+
|
|
268
|
+
**Type**: `(messages: string[], request: Rfc9457Request) => ProblemDetail`
|
|
269
|
+
|
|
270
|
+
Overrides the default Tier 1 validation error response. Receives the flat string array from `BadRequestException.getResponse().message`. Only applies to Tier 1 (flat string) validation errors — Tier 2 structured errors from `Rfc9457ValidationException` bypass this callback.
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
Rfc9457Module.forRoot({
|
|
274
|
+
validationExceptionMapper: (messages, request) => ({
|
|
275
|
+
type: 'https://api.example.com/problems/validation-error',
|
|
276
|
+
title: 'Validation Error',
|
|
277
|
+
status: 400,
|
|
278
|
+
detail: 'One or more fields failed validation',
|
|
279
|
+
violations: messages,
|
|
280
|
+
}),
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Async Configuration
|
|
287
|
+
|
|
288
|
+
Use `Rfc9457Module.forRootAsync()` to inject configuration from a service such as `ConfigService`.
|
|
289
|
+
|
|
290
|
+
### `useFactory`
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
import { Module } from '@nestjs/common';
|
|
294
|
+
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
295
|
+
import { Rfc9457Module } from '@camcima/nestjs-rfc9457';
|
|
296
|
+
|
|
297
|
+
@Module({
|
|
298
|
+
imports: [
|
|
299
|
+
ConfigModule.forRoot(),
|
|
300
|
+
Rfc9457Module.forRootAsync({
|
|
301
|
+
imports: [ConfigModule],
|
|
302
|
+
inject: [ConfigService],
|
|
303
|
+
useFactory: (config: ConfigService) => ({
|
|
304
|
+
typeBaseUri: config.get<string>('PROBLEM_TYPE_BASE_URI'),
|
|
305
|
+
instanceStrategy: 'uuid',
|
|
306
|
+
catchAllExceptions: config.get<boolean>('CATCH_ALL_EXCEPTIONS', false),
|
|
307
|
+
}),
|
|
308
|
+
}),
|
|
309
|
+
],
|
|
310
|
+
})
|
|
311
|
+
export class AppModule {}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### `useClass`
|
|
315
|
+
|
|
316
|
+
Implement the `Rfc9457OptionsFactory` interface:
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
import { Injectable } from '@nestjs/common';
|
|
320
|
+
import { Rfc9457OptionsFactory, Rfc9457ModuleOptions } from '@camcima/nestjs-rfc9457';
|
|
321
|
+
|
|
322
|
+
@Injectable()
|
|
323
|
+
export class Rfc9457ConfigService implements Rfc9457OptionsFactory {
|
|
324
|
+
createRfc9457Options(): Rfc9457ModuleOptions {
|
|
325
|
+
return {
|
|
326
|
+
typeBaseUri: 'https://api.example.com/problems',
|
|
327
|
+
instanceStrategy: 'uuid',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
Rfc9457Module.forRootAsync({
|
|
335
|
+
useClass: Rfc9457ConfigService,
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### `useExisting`
|
|
340
|
+
|
|
341
|
+
Reuse an existing provider that implements `Rfc9457OptionsFactory`:
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
Rfc9457Module.forRootAsync({
|
|
345
|
+
imports: [SharedConfigModule],
|
|
346
|
+
useExisting: SharedConfigService,
|
|
347
|
+
});
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Custom Exception Types
|
|
353
|
+
|
|
354
|
+
Use the `@ProblemType()` decorator to attach RFC 9457 problem type metadata to your exception classes. The decorator stores a **template** with type identity fields (`type`, `title`, `status`). Occurrence-specific fields (`detail`, `instance`) are always resolved at runtime by the factory from the exception message and the configured instance strategy.
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
import { HttpException } from '@nestjs/common';
|
|
358
|
+
import { ProblemType } from '@camcima/nestjs-rfc9457';
|
|
359
|
+
|
|
360
|
+
@ProblemType({
|
|
361
|
+
type: 'https://api.example.com/problems/insufficient-funds',
|
|
362
|
+
title: 'Insufficient Funds',
|
|
363
|
+
status: 422,
|
|
364
|
+
})
|
|
365
|
+
export class InsufficientFundsException extends HttpException {
|
|
366
|
+
constructor(
|
|
367
|
+
public readonly balance: number,
|
|
368
|
+
public readonly required: number,
|
|
369
|
+
) {
|
|
370
|
+
super(`Balance ${balance} is less than required ${required}`, 422);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
When this exception is thrown, the response is:
|
|
376
|
+
|
|
377
|
+
```json
|
|
378
|
+
{
|
|
379
|
+
"type": "https://api.example.com/problems/insufficient-funds",
|
|
380
|
+
"title": "Insufficient Funds",
|
|
381
|
+
"status": 422,
|
|
382
|
+
"detail": "Balance 50 is less than required 100"
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The decorator accepts a `ProblemTypeMetadata` object:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
interface ProblemTypeMetadata {
|
|
390
|
+
type?: string; // URI for the problem type
|
|
391
|
+
title?: string; // Short human-readable summary
|
|
392
|
+
status?: number; // HTTP status code
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
All three fields are optional. If `status` is omitted, the factory uses `exception.getStatus()` for `HttpException` subclasses or falls back to `500` in catch-all mode. If `type` is omitted and `typeBaseUri` is configured, the slug for the status code is used.
|
|
397
|
+
|
|
398
|
+
### Inheritance
|
|
399
|
+
|
|
400
|
+
Metadata lookup walks the prototype chain, so child classes automatically inherit their parent's `@ProblemType()` metadata:
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
// Parent defines the problem type
|
|
404
|
+
@ProblemType({
|
|
405
|
+
type: 'https://api.example.com/problems/payment-error',
|
|
406
|
+
title: 'Payment Error',
|
|
407
|
+
status: 402,
|
|
408
|
+
})
|
|
409
|
+
export class PaymentException extends HttpException {
|
|
410
|
+
constructor(message: string) {
|
|
411
|
+
super(message, 402);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Child inherits parent's @ProblemType() metadata
|
|
416
|
+
export class CardDeclinedException extends PaymentException {
|
|
417
|
+
constructor() {
|
|
418
|
+
super('Card was declined');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
A child class can **fully override** the parent's metadata by applying its own `@ProblemType()` decorator. There is no merging — the child's decorator replaces the parent's entirely.
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
@ProblemType({
|
|
427
|
+
type: 'https://api.example.com/problems/card-declined',
|
|
428
|
+
title: 'Card Declined',
|
|
429
|
+
status: 402,
|
|
430
|
+
})
|
|
431
|
+
export class CardDeclinedException extends PaymentException {
|
|
432
|
+
constructor() {
|
|
433
|
+
super('Card was declined');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`@ProblemType()` can also decorate plain `Error` subclasses (not extending `HttpException`), but these are only handled by the factory when `catchAllExceptions: true` is set.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## Validation Integration
|
|
443
|
+
|
|
444
|
+
### Tier 1 — Automatic (zero config)
|
|
445
|
+
|
|
446
|
+
When NestJS's `ValidationPipe` rejects a request, it throws a `BadRequestException` whose response contains a `message` array of strings. The library detects this automatically and produces a structured validation error response with no configuration required.
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
// main.ts — standard ValidationPipe setup, nothing extra needed
|
|
450
|
+
app.useGlobalPipes(new ValidationPipe());
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Response:
|
|
454
|
+
|
|
455
|
+
```json
|
|
456
|
+
{
|
|
457
|
+
"type": "about:blank",
|
|
458
|
+
"title": "Bad Request",
|
|
459
|
+
"status": 400,
|
|
460
|
+
"detail": "Request validation failed",
|
|
461
|
+
"errors": ["email must be an email", "age must not be less than 0"]
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
To customize the Tier 1 response, use the `validationExceptionMapper` option described in the [Configuration](#configuration) section.
|
|
466
|
+
|
|
467
|
+
### Tier 2 — Enhanced structured errors (opt-in)
|
|
468
|
+
|
|
469
|
+
For rich, structured validation output with `property`, `constraints`, and nested `children` arrays, use the `createRfc9457ValidationPipeExceptionFactory` helper.
|
|
470
|
+
|
|
471
|
+
**Step 1** — Install `class-validator` if you have not already:
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
npm install class-validator class-transformer
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**Step 2** — Use the factory as the `ValidationPipe` exception factory:
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
// main.ts
|
|
481
|
+
import { ValidationPipe } from '@nestjs/common';
|
|
482
|
+
import { createRfc9457ValidationPipeExceptionFactory } from '@camcima/nestjs-rfc9457';
|
|
483
|
+
|
|
484
|
+
app.useGlobalPipes(
|
|
485
|
+
new ValidationPipe({
|
|
486
|
+
exceptionFactory: createRfc9457ValidationPipeExceptionFactory(),
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Response for a DTO with nested validation:
|
|
492
|
+
|
|
493
|
+
```json
|
|
494
|
+
{
|
|
495
|
+
"type": "about:blank",
|
|
496
|
+
"title": "Bad Request",
|
|
497
|
+
"status": 400,
|
|
498
|
+
"detail": "Request validation failed",
|
|
499
|
+
"errors": [
|
|
500
|
+
{
|
|
501
|
+
"property": "email",
|
|
502
|
+
"constraints": {
|
|
503
|
+
"isEmail": "email must be an email"
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
"property": "address",
|
|
508
|
+
"children": [
|
|
509
|
+
{
|
|
510
|
+
"property": "zip",
|
|
511
|
+
"constraints": {
|
|
512
|
+
"isPostalCode": "zip must be a postal code"
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
]
|
|
516
|
+
}
|
|
517
|
+
]
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Nested validation errors are preserved as `children` arrays matching the `class-validator` `ValidationError` tree. They are **not** flattened to dotted paths (e.g., `"address.zip"`) — the original structure is preserved.
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## Advanced Usage
|
|
526
|
+
|
|
527
|
+
### Using `ProblemDetailsFactory` directly
|
|
528
|
+
|
|
529
|
+
`ProblemDetailsFactory` is an injectable service exported by `Rfc9457Module`. You can inject it into any provider to produce Problem Details responses in contexts outside the standard HTTP filter — for example, GraphQL error formatters or microservice exception handlers.
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
import { Injectable } from '@nestjs/common';
|
|
533
|
+
import { ProblemDetailsFactory, Rfc9457Request } from '@camcima/nestjs-rfc9457';
|
|
534
|
+
|
|
535
|
+
@Injectable()
|
|
536
|
+
export class GraphQLErrorFormatter {
|
|
537
|
+
constructor(private readonly problemDetailsFactory: ProblemDetailsFactory) {}
|
|
538
|
+
|
|
539
|
+
format(exception: unknown, context: { path: string; method: string }) {
|
|
540
|
+
const request: Rfc9457Request = {
|
|
541
|
+
url: context.path,
|
|
542
|
+
method: context.method,
|
|
543
|
+
};
|
|
544
|
+
const { status, body } = this.problemDetailsFactory.create(exception, request);
|
|
545
|
+
return { extensions: { problem: body, httpStatus: status } };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
The `create` method signature is:
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
create(exception: unknown, request: Rfc9457Request): { status: number; body: ProblemDetail }
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
- `status` is the definitive HTTP status code to use for the transport layer.
|
|
557
|
+
- `body` is the RFC 9457 Problem Details object to serialize.
|
|
558
|
+
|
|
559
|
+
The factory applies the full resolution chain (mapper → decorator → validation → default → fallback) and all normalization rules (`type`, `instance`, `title`) regardless of how it is called.
|
|
560
|
+
|
|
561
|
+
### Custom exception filter
|
|
562
|
+
|
|
563
|
+
You can build your own filter on top of `ProblemDetailsFactory` if you need to intercept specific exception types before the global filter sees them:
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
import { Catch, ArgumentsHost, HttpException } from '@nestjs/common';
|
|
567
|
+
import { BaseExceptionFilter } from '@nestjs/core';
|
|
568
|
+
import { ProblemDetailsFactory } from '@camcima/nestjs-rfc9457';
|
|
569
|
+
|
|
570
|
+
@Catch(MySpecialException)
|
|
571
|
+
export class MySpecialExceptionFilter extends BaseExceptionFilter {
|
|
572
|
+
constructor(private readonly factory: ProblemDetailsFactory) {
|
|
573
|
+
super();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
catch(exception: MySpecialException, host: ArgumentsHost) {
|
|
577
|
+
const ctx = host.switchToHttp();
|
|
578
|
+
const request = ctx.getRequest();
|
|
579
|
+
const response = ctx.getResponse();
|
|
580
|
+
|
|
581
|
+
const { status, body } = this.factory.create(exception, request);
|
|
582
|
+
response.status(status).json(body);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
---
|
|
588
|
+
|
|
589
|
+
## API Reference
|
|
590
|
+
|
|
591
|
+
| Export | Kind | Description |
|
|
592
|
+
| --------------------------------------------- | ---------------- | --------------------------------------------------------------------------- |
|
|
593
|
+
| `Rfc9457Module` | Class | Dynamic module. Use `forRoot(options?)` or `forRootAsync(options)` |
|
|
594
|
+
| `ProblemDetailsFactory` | Injectable class | Core resolver; injectable for use outside the HTTP filter |
|
|
595
|
+
| `Rfc9457ExceptionFilter` | Injectable class | Global exception filter; registered automatically by the module |
|
|
596
|
+
| `ProblemType` | Decorator | Class decorator that attaches problem type metadata to exception classes |
|
|
597
|
+
| `ProblemDetail` | Interface | RFC 9457 response body shape with index signature for extension members |
|
|
598
|
+
| `ProblemTypeMetadata` | Interface | Decorator options (`type`, `title`, `status`) |
|
|
599
|
+
| `Rfc9457ModuleOptions` | Interface | Options accepted by `forRoot()` |
|
|
600
|
+
| `Rfc9457OptionsFactory` | Interface | Implement for `useClass` / `useExisting` async patterns |
|
|
601
|
+
| `Rfc9457AsyncModuleOptions` | Interface | Options accepted by `forRootAsync()` |
|
|
602
|
+
| `InstanceStrategy` | Type | Union type for `instanceStrategy` option |
|
|
603
|
+
| `Rfc9457Request` | Interface | Minimal request context compatible with Express and Fastify |
|
|
604
|
+
| `Rfc9457ValidationException` | Class | Exception wrapping structured `ValidationError[]`; thrown by Tier 2 factory |
|
|
605
|
+
| `createRfc9457ValidationPipeExceptionFactory` | Function | Returns an `exceptionFactory` for `ValidationPipe` to enable Tier 2 errors |
|
|
606
|
+
| `RFC9457_MODULE_OPTIONS` | Symbol | DI token for the module options |
|
|
607
|
+
| `PROBLEM_CONTENT_TYPE` | Constant | `'application/problem+json'` |
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
## Example Responses
|
|
612
|
+
|
|
613
|
+
### Basic 404 (no `typeBaseUri`)
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
throw new NotFoundException('User 42 not found');
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
```json
|
|
620
|
+
{
|
|
621
|
+
"type": "about:blank",
|
|
622
|
+
"title": "Not Found",
|
|
623
|
+
"status": 404,
|
|
624
|
+
"detail": "User 42 not found"
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### Basic 404 (with `typeBaseUri` and `instanceStrategy: 'request-uri'`)
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
Rfc9457Module.forRoot({
|
|
632
|
+
typeBaseUri: 'https://api.example.com/problems',
|
|
633
|
+
instanceStrategy: 'request-uri',
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
throw new NotFoundException('User 42 not found');
|
|
637
|
+
// request path: /api/users/42
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
```json
|
|
641
|
+
{
|
|
642
|
+
"type": "https://api.example.com/problems/not-found",
|
|
643
|
+
"title": "Not Found",
|
|
644
|
+
"status": 404,
|
|
645
|
+
"detail": "User 42 not found",
|
|
646
|
+
"instance": "/api/users/42"
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### Validation error (Tier 2 structured)
|
|
651
|
+
|
|
652
|
+
```json
|
|
653
|
+
{
|
|
654
|
+
"type": "about:blank",
|
|
655
|
+
"title": "Bad Request",
|
|
656
|
+
"status": 400,
|
|
657
|
+
"detail": "Request validation failed",
|
|
658
|
+
"errors": [
|
|
659
|
+
{
|
|
660
|
+
"property": "email",
|
|
661
|
+
"constraints": {
|
|
662
|
+
"isEmail": "email must be an email"
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
"property": "address",
|
|
667
|
+
"children": [
|
|
668
|
+
{
|
|
669
|
+
"property": "zip",
|
|
670
|
+
"constraints": {
|
|
671
|
+
"isPostalCode": "zip must be a postal code"
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
]
|
|
675
|
+
}
|
|
676
|
+
]
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
### Custom problem type with `@ProblemType()`
|
|
681
|
+
|
|
682
|
+
```typescript
|
|
683
|
+
@ProblemType({
|
|
684
|
+
type: 'https://api.example.com/problems/insufficient-funds',
|
|
685
|
+
title: 'Insufficient Funds',
|
|
686
|
+
status: 422,
|
|
687
|
+
})
|
|
688
|
+
export class InsufficientFundsException extends HttpException {
|
|
689
|
+
/* ... */
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
throw new InsufficientFundsException(50, 100);
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
```json
|
|
696
|
+
{
|
|
697
|
+
"type": "https://api.example.com/problems/insufficient-funds",
|
|
698
|
+
"title": "Insufficient Funds",
|
|
699
|
+
"status": 422,
|
|
700
|
+
"detail": "Balance 50 is less than required 100"
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
### Catch-all 500 (with `catchAllExceptions: true`)
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
throw new Error('Connection refused');
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
```json
|
|
711
|
+
{
|
|
712
|
+
"type": "about:blank",
|
|
713
|
+
"title": "Internal Server Error",
|
|
714
|
+
"status": 500
|
|
715
|
+
}
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Internal error messages are never included in the response to avoid leaking sensitive information.
|
|
719
|
+
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
## Contributing
|
|
723
|
+
|
|
724
|
+
Contributions are welcome. Please open an issue before submitting a pull request for significant changes.
|
|
725
|
+
|
|
726
|
+
```bash
|
|
727
|
+
# Clone the repository
|
|
728
|
+
git clone https://github.com/camcima/nestjs-rfc9457.git
|
|
729
|
+
cd nestjs-rfc9457
|
|
730
|
+
|
|
731
|
+
# Install dependencies
|
|
732
|
+
npm install
|
|
733
|
+
|
|
734
|
+
# Run unit tests
|
|
735
|
+
npm run test:unit
|
|
736
|
+
|
|
737
|
+
# Run e2e tests
|
|
738
|
+
npm run test:e2e
|
|
739
|
+
|
|
740
|
+
# Run all tests with coverage
|
|
741
|
+
npm run test:cov
|
|
742
|
+
|
|
743
|
+
# Build
|
|
744
|
+
npm run build
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
This project uses [Conventional Commits](https://www.conventionalcommits.org/) enforced by commitlint, and [Lefthook](https://github.com/evilmartians/lefthook) for pre-commit hooks (lint + format on staged files).
|
|
748
|
+
|
|
749
|
+
---
|
|
750
|
+
|
|
751
|
+
## License
|
|
752
|
+
|
|
753
|
+
[MIT](./LICENSE)
|