create-forgeon 0.1.22 → 0.1.24
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/package.json +1 -1
- package/src/core/docs.mjs +12 -9
- package/src/core/docs.test.mjs +18 -14
- package/src/modules/executor.test.mjs +4 -0
- package/src/modules/i18n.mjs +0 -5
- package/src/presets/i18n.mjs +9 -82
- package/templates/base/apps/api/src/app.module.ts +7 -7
- package/templates/base/apps/api/src/main.ts +8 -9
- package/templates/base/packages/core/README.md +1 -0
- package/templates/base/packages/core/src/errors/core-errors.module.ts +8 -0
- package/templates/base/packages/core/src/errors/core-exception.filter.ts +137 -0
- package/templates/base/packages/core/src/errors/error.types.ts +19 -0
- package/templates/base/packages/core/src/errors/index.ts +3 -0
- package/templates/base/packages/core/src/index.ts +1 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/23_error_handling.md +11 -0
- package/templates/docs-fragments/AI_PROJECT/34_error_handling.md +7 -0
- package/templates/docs-fragments/README/41_error_handling.md +27 -0
- package/templates/base/apps/api/src/common/filters/app-exception.filter.ts +0 -130
package/package.json
CHANGED
package/src/core/docs.mjs
CHANGED
|
@@ -64,11 +64,12 @@ export function generateDocs(targetRoot, options, packageRoot) {
|
|
|
64
64
|
} else {
|
|
65
65
|
readmeFragments.push('31_proxy_preset_none');
|
|
66
66
|
}
|
|
67
|
-
readmeFragments.push('32_prisma_container_start');
|
|
68
|
-
if (options.i18nEnabled) {
|
|
69
|
-
readmeFragments.push('40_i18n');
|
|
70
|
-
}
|
|
71
|
-
readmeFragments.push('
|
|
67
|
+
readmeFragments.push('32_prisma_container_start');
|
|
68
|
+
if (options.i18nEnabled) {
|
|
69
|
+
readmeFragments.push('40_i18n');
|
|
70
|
+
}
|
|
71
|
+
readmeFragments.push('41_error_handling');
|
|
72
|
+
readmeFragments.push('90_next_steps');
|
|
72
73
|
|
|
73
74
|
const aiProjectFragments = ['00_title', '10_what_is', '20_structure_base'];
|
|
74
75
|
if (options.i18nEnabled) {
|
|
@@ -80,10 +81,11 @@ export function generateDocs(targetRoot, options, packageRoot) {
|
|
|
80
81
|
} else {
|
|
81
82
|
aiProjectFragments.push('32_proxy_notes');
|
|
82
83
|
}
|
|
83
|
-
if (options.i18nEnabled) {
|
|
84
|
-
aiProjectFragments.push('33_i18n_notes');
|
|
85
|
-
}
|
|
86
|
-
aiProjectFragments.push('
|
|
84
|
+
if (options.i18nEnabled) {
|
|
85
|
+
aiProjectFragments.push('33_i18n_notes');
|
|
86
|
+
}
|
|
87
|
+
aiProjectFragments.push('34_error_handling');
|
|
88
|
+
aiProjectFragments.push('40_change_boundaries_base');
|
|
87
89
|
if (options.proxy !== 'none') {
|
|
88
90
|
aiProjectFragments.push('41_change_boundaries_docker');
|
|
89
91
|
}
|
|
@@ -97,6 +99,7 @@ export function generateDocs(targetRoot, options, packageRoot) {
|
|
|
97
99
|
aiArchitectureFragments.push('21_env_i18n');
|
|
98
100
|
}
|
|
99
101
|
aiArchitectureFragments.push('22_ts_module_policy');
|
|
102
|
+
aiArchitectureFragments.push('23_error_handling');
|
|
100
103
|
aiArchitectureFragments.push('30_default_db', '31_docker_runtime', '32_scope_freeze');
|
|
101
104
|
aiArchitectureFragments.push('40_docs_generation', '50_extension_points');
|
|
102
105
|
|
package/src/core/docs.test.mjs
CHANGED
|
@@ -38,14 +38,16 @@ describe('generateDocs', () => {
|
|
|
38
38
|
const projectDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'PROJECT.md'));
|
|
39
39
|
const architectureDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md'));
|
|
40
40
|
|
|
41
|
-
assert.match(readme, /Docker\/infra: `enabled`/);
|
|
42
|
-
assert.match(readme, /Quick Start \(Docker\)/);
|
|
43
|
-
assert.match(readme, /Proxy Preset: none/);
|
|
44
|
-
assert.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assert.match(projectDoc,
|
|
48
|
-
assert.
|
|
41
|
+
assert.match(readme, /Docker\/infra: `enabled`/);
|
|
42
|
+
assert.match(readme, /Quick Start \(Docker\)/);
|
|
43
|
+
assert.match(readme, /Proxy Preset: none/);
|
|
44
|
+
assert.match(readme, /Error Handling \(`core-errors`\)/);
|
|
45
|
+
assert.doesNotMatch(readme, /i18n Configuration/);
|
|
46
|
+
|
|
47
|
+
assert.match(projectDoc, /### Docker mode/);
|
|
48
|
+
assert.match(projectDoc, /Active proxy preset: `none`/);
|
|
49
|
+
assert.match(projectDoc, /CoreErrorsModule/);
|
|
50
|
+
assert.doesNotMatch(projectDoc, /packages\/i18n/);
|
|
49
51
|
|
|
50
52
|
assert.match(architectureDoc, /infra\/\*/);
|
|
51
53
|
assert.doesNotMatch(architectureDoc, /I18N_ENABLED/);
|
|
@@ -78,12 +80,14 @@ describe('generateDocs', () => {
|
|
|
78
80
|
const projectDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'PROJECT.md'));
|
|
79
81
|
const architectureDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md'));
|
|
80
82
|
|
|
81
|
-
assert.match(readme, /Quick Start \(Docker\)/);
|
|
82
|
-
assert.match(readme, /Proxy Preset: Caddy/);
|
|
83
|
-
assert.match(readme, /i18n Configuration/);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
assert.match(projectDoc,
|
|
83
|
+
assert.match(readme, /Quick Start \(Docker\)/);
|
|
84
|
+
assert.match(readme, /Proxy Preset: Caddy/);
|
|
85
|
+
assert.match(readme, /i18n Configuration/);
|
|
86
|
+
assert.match(readme, /Error Handling \(`core-errors`\)/);
|
|
87
|
+
|
|
88
|
+
assert.match(projectDoc, /`infra` - Docker Compose \(always\) \+ proxy preset \(`caddy`\)/);
|
|
89
|
+
assert.match(projectDoc, /Main proxy config: `infra\/caddy\/Caddyfile`/);
|
|
90
|
+
assert.match(projectDoc, /CoreExceptionFilter/);
|
|
87
91
|
|
|
88
92
|
assert.match(architectureDoc, /infra\/\*/);
|
|
89
93
|
assert.match(architectureDoc, /I18N_DEFAULT_LANG/);
|
|
@@ -115,6 +115,10 @@ describe('addModule', () => {
|
|
|
115
115
|
assert.match(appModule, /i18nConfig/);
|
|
116
116
|
assert.match(appModule, /i18nEnvSchema/);
|
|
117
117
|
assert.match(appModule, /CoreConfigModule/);
|
|
118
|
+
assert.match(appModule, /CoreErrorsModule/);
|
|
119
|
+
|
|
120
|
+
const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
|
|
121
|
+
assert.match(mainTs, /CoreExceptionFilter/);
|
|
118
122
|
|
|
119
123
|
const forgeonI18nModule = fs.readFileSync(
|
|
120
124
|
path.join(projectRoot, 'packages', 'i18n', 'src', 'forgeon-i18n.module.ts'),
|
package/src/modules/i18n.mjs
CHANGED
|
@@ -313,11 +313,6 @@ export function applyI18nModule({ packageRoot, targetRoot }) {
|
|
|
313
313
|
targetRoot,
|
|
314
314
|
path.join('apps', 'api', 'src', 'health', 'health.controller.ts'),
|
|
315
315
|
);
|
|
316
|
-
copyFromBase(
|
|
317
|
-
packageRoot,
|
|
318
|
-
targetRoot,
|
|
319
|
-
path.join('apps', 'api', 'src', 'common', 'filters', 'app-exception.filter.ts'),
|
|
320
|
-
);
|
|
321
316
|
|
|
322
317
|
patchI18nPackage(targetRoot);
|
|
323
318
|
patchApiPackage(targetRoot);
|
package/src/presets/i18n.mjs
CHANGED
|
@@ -110,10 +110,9 @@ export function applyI18nDisabled(targetRoot) {
|
|
|
110
110
|
appModulePath,
|
|
111
111
|
`import { Module } from '@nestjs/common';
|
|
112
112
|
import { ConfigModule } from '@nestjs/config';
|
|
113
|
-
import { CoreConfigModule, coreConfig, validateCoreEnv } from '@forgeon/core';
|
|
113
|
+
import { CoreConfigModule, CoreErrorsModule, coreConfig, validateCoreEnv } from '@forgeon/core';
|
|
114
114
|
import { HealthController } from './health/health.controller';
|
|
115
115
|
import { PrismaModule } from './prisma/prisma.module';
|
|
116
|
-
import { AppExceptionFilter } from './common/filters/app-exception.filter';
|
|
117
116
|
|
|
118
117
|
@Module({
|
|
119
118
|
imports: [
|
|
@@ -124,15 +123,15 @@ import { AppExceptionFilter } from './common/filters/app-exception.filter';
|
|
|
124
123
|
envFilePath: '.env',
|
|
125
124
|
}),
|
|
126
125
|
CoreConfigModule,
|
|
126
|
+
CoreErrorsModule,
|
|
127
127
|
PrismaModule,
|
|
128
128
|
],
|
|
129
129
|
controllers: [HealthController],
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
);
|
|
130
|
+
})
|
|
131
|
+
export class AppModule {}
|
|
132
|
+
`,
|
|
133
|
+
'utf8',
|
|
134
|
+
);
|
|
136
135
|
|
|
137
136
|
const healthControllerPath = path.join(
|
|
138
137
|
targetRoot,
|
|
@@ -166,80 +165,8 @@ export class HealthController {
|
|
|
166
165
|
'utf8',
|
|
167
166
|
);
|
|
168
167
|
|
|
169
|
-
|
|
170
|
-
targetRoot,
|
|
171
|
-
'apps',
|
|
172
|
-
'api',
|
|
173
|
-
'src',
|
|
174
|
-
'common',
|
|
175
|
-
'filters',
|
|
176
|
-
'app-exception.filter.ts',
|
|
177
|
-
);
|
|
178
|
-
fs.writeFileSync(
|
|
179
|
-
filterPath,
|
|
180
|
-
`import {
|
|
181
|
-
ArgumentsHost,
|
|
182
|
-
Catch,
|
|
183
|
-
ExceptionFilter,
|
|
184
|
-
HttpException,
|
|
185
|
-
HttpStatus,
|
|
186
|
-
Injectable,
|
|
187
|
-
} from '@nestjs/common';
|
|
188
|
-
import { Response } from 'express';
|
|
189
|
-
|
|
190
|
-
@Injectable()
|
|
191
|
-
@Catch()
|
|
192
|
-
export class AppExceptionFilter implements ExceptionFilter {
|
|
193
|
-
catch(exception: unknown, host: ArgumentsHost): void {
|
|
194
|
-
const context = host.switchToHttp();
|
|
195
|
-
const response = context.getResponse<Response>();
|
|
196
|
-
|
|
197
|
-
const status =
|
|
198
|
-
exception instanceof HttpException
|
|
199
|
-
? exception.getStatus()
|
|
200
|
-
: HttpStatus.INTERNAL_SERVER_ERROR;
|
|
201
|
-
|
|
202
|
-
const payload =
|
|
203
|
-
exception instanceof HttpException
|
|
204
|
-
? exception.getResponse()
|
|
205
|
-
: { message: 'Internal server error' };
|
|
206
|
-
|
|
207
|
-
const message =
|
|
208
|
-
typeof payload === 'object' && payload !== null && 'message' in payload
|
|
209
|
-
? Array.isArray((payload as { message?: unknown }).message)
|
|
210
|
-
? String((payload as { message: unknown[] }).message[0] ?? 'Internal server error')
|
|
211
|
-
: String((payload as { message?: unknown }).message ?? 'Internal server error')
|
|
212
|
-
: typeof payload === 'string'
|
|
213
|
-
? payload
|
|
214
|
-
: 'Internal server error';
|
|
215
|
-
|
|
216
|
-
response.status(status).json({
|
|
217
|
-
error: {
|
|
218
|
-
code: this.resolveCode(status),
|
|
219
|
-
message,
|
|
220
|
-
},
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private resolveCode(status: number): string {
|
|
225
|
-
switch (status) {
|
|
226
|
-
case HttpStatus.BAD_REQUEST:
|
|
227
|
-
return 'validation_error';
|
|
228
|
-
case HttpStatus.UNAUTHORIZED:
|
|
229
|
-
return 'unauthorized';
|
|
230
|
-
case HttpStatus.FORBIDDEN:
|
|
231
|
-
return 'forbidden';
|
|
232
|
-
case HttpStatus.NOT_FOUND:
|
|
233
|
-
return 'not_found';
|
|
234
|
-
case HttpStatus.CONFLICT:
|
|
235
|
-
return 'conflict';
|
|
236
|
-
default:
|
|
237
|
-
return 'internal_error';
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
`,
|
|
242
|
-
'utf8',
|
|
168
|
+
removeIfExists(
|
|
169
|
+
path.join(targetRoot, 'apps', 'api', 'src', 'common', 'filters', 'app-exception.filter.ts'),
|
|
243
170
|
);
|
|
244
171
|
}
|
|
245
172
|
|
|
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|
|
2
2
|
import { ConfigModule } from '@nestjs/config';
|
|
3
3
|
import {
|
|
4
4
|
CoreConfigModule,
|
|
5
|
+
CoreErrorsModule,
|
|
5
6
|
coreConfig,
|
|
6
7
|
coreEnvSchema,
|
|
7
8
|
createEnvValidator,
|
|
@@ -10,7 +11,6 @@ import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';
|
|
|
10
11
|
import { join } from 'path';
|
|
11
12
|
import { HealthController } from './health/health.controller';
|
|
12
13
|
import { PrismaModule } from './prisma/prisma.module';
|
|
13
|
-
import { AppExceptionFilter } from './common/filters/app-exception.filter';
|
|
14
14
|
|
|
15
15
|
const i18nPath = join(__dirname, '..', '..', '..', 'resources', 'i18n');
|
|
16
16
|
|
|
@@ -23,13 +23,13 @@ const i18nPath = join(__dirname, '..', '..', '..', 'resources', 'i18n');
|
|
|
23
23
|
envFilePath: '.env',
|
|
24
24
|
}),
|
|
25
25
|
CoreConfigModule,
|
|
26
|
+
CoreErrorsModule,
|
|
26
27
|
ForgeonI18nModule.register({
|
|
27
28
|
path: i18nPath,
|
|
28
29
|
}),
|
|
29
|
-
PrismaModule,
|
|
30
|
-
],
|
|
31
|
-
controllers: [HealthController],
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
export class AppModule {}
|
|
30
|
+
PrismaModule,
|
|
31
|
+
],
|
|
32
|
+
controllers: [HealthController],
|
|
33
|
+
})
|
|
34
|
+
export class AppModule {}
|
|
35
35
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { ValidationPipe } from '@nestjs/common';
|
|
3
|
-
import { CoreConfigService } from '@forgeon/core';
|
|
3
|
+
import { CoreConfigService, CoreExceptionFilter } from '@forgeon/core';
|
|
4
4
|
import { NestFactory } from '@nestjs/core';
|
|
5
5
|
import { AppModule } from './app.module';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const app = await NestFactory.create(AppModule);
|
|
6
|
+
|
|
7
|
+
async function bootstrap() {
|
|
8
|
+
const app = await NestFactory.create(AppModule);
|
|
10
9
|
|
|
11
10
|
const coreConfigService = app.get(CoreConfigService);
|
|
12
11
|
|
|
@@ -14,10 +13,10 @@ async function bootstrap() {
|
|
|
14
13
|
app.useGlobalPipes(
|
|
15
14
|
new ValidationPipe({
|
|
16
15
|
whitelist: true,
|
|
17
|
-
transform: true,
|
|
18
|
-
}),
|
|
19
|
-
);
|
|
20
|
-
app.useGlobalFilters(app.get(
|
|
16
|
+
transform: true,
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
app.useGlobalFilters(app.get(CoreExceptionFilter));
|
|
21
20
|
|
|
22
21
|
await app.listen(coreConfigService.port);
|
|
23
22
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentsHost,
|
|
3
|
+
Catch,
|
|
4
|
+
ExceptionFilter,
|
|
5
|
+
HttpException,
|
|
6
|
+
HttpStatus,
|
|
7
|
+
Injectable,
|
|
8
|
+
} from '@nestjs/common';
|
|
9
|
+
import { AppErrorDetails, ValidationErrorDetail } from './error.types';
|
|
10
|
+
|
|
11
|
+
type HttpLikeResponse = {
|
|
12
|
+
status: (statusCode: number) => {
|
|
13
|
+
json: (body: unknown) => void;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
@Injectable()
|
|
18
|
+
@Catch()
|
|
19
|
+
export class CoreExceptionFilter implements ExceptionFilter {
|
|
20
|
+
catch(exception: unknown, host: ArgumentsHost): void {
|
|
21
|
+
const context = host.switchToHttp();
|
|
22
|
+
const response = context.getResponse<HttpLikeResponse>();
|
|
23
|
+
const request = context.getRequest<{ headers?: Record<string, unknown> }>();
|
|
24
|
+
|
|
25
|
+
const status =
|
|
26
|
+
exception instanceof HttpException
|
|
27
|
+
? exception.getStatus()
|
|
28
|
+
: HttpStatus.INTERNAL_SERVER_ERROR;
|
|
29
|
+
|
|
30
|
+
const payload =
|
|
31
|
+
exception instanceof HttpException
|
|
32
|
+
? exception.getResponse()
|
|
33
|
+
: { message: 'Internal server error' };
|
|
34
|
+
|
|
35
|
+
const requestId = this.resolveRequestId(request?.headers);
|
|
36
|
+
const details = this.resolveDetails(payload, status);
|
|
37
|
+
const timestamp = new Date().toISOString();
|
|
38
|
+
|
|
39
|
+
response.status(status).json({
|
|
40
|
+
error: {
|
|
41
|
+
code: this.resolveCode(status),
|
|
42
|
+
message: this.resolveMessage(payload, status),
|
|
43
|
+
status,
|
|
44
|
+
...(details !== undefined ? { details } : {}),
|
|
45
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
46
|
+
timestamp,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private resolveCode(status: number): string {
|
|
52
|
+
switch (status) {
|
|
53
|
+
case HttpStatus.BAD_REQUEST:
|
|
54
|
+
return 'validation_error';
|
|
55
|
+
case HttpStatus.UNAUTHORIZED:
|
|
56
|
+
return 'unauthorized';
|
|
57
|
+
case HttpStatus.FORBIDDEN:
|
|
58
|
+
return 'forbidden';
|
|
59
|
+
case HttpStatus.NOT_FOUND:
|
|
60
|
+
return 'not_found';
|
|
61
|
+
case HttpStatus.CONFLICT:
|
|
62
|
+
return 'conflict';
|
|
63
|
+
default:
|
|
64
|
+
return 'internal_error';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private resolveMessage(payload: unknown, status: number): string {
|
|
69
|
+
if (status === HttpStatus.NOT_FOUND) {
|
|
70
|
+
return 'Resource not found';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof payload === 'string') {
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof payload === 'object' && payload !== null) {
|
|
78
|
+
const obj = payload as { message?: string | string[] };
|
|
79
|
+
if (Array.isArray(obj.message) && obj.message.length > 0) {
|
|
80
|
+
return String(obj.message[0]);
|
|
81
|
+
}
|
|
82
|
+
if (typeof obj.message === 'string' && obj.message.length > 0) {
|
|
83
|
+
return obj.message;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return status >= 500 ? 'Internal server error' : 'Request failed';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private resolveDetails(payload: unknown, status: number): AppErrorDetails | undefined {
|
|
91
|
+
if (status !== HttpStatus.BAD_REQUEST) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const obj = payload as { message?: unknown };
|
|
100
|
+
const messages = Array.isArray(obj.message)
|
|
101
|
+
? obj.message.filter((item): item is string => typeof item === 'string')
|
|
102
|
+
: typeof obj.message === 'string'
|
|
103
|
+
? [obj.message]
|
|
104
|
+
: [];
|
|
105
|
+
|
|
106
|
+
if (messages.length === 0) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const details: ValidationErrorDetail[] = messages.map((message) => {
|
|
111
|
+
const field = this.extractField(message);
|
|
112
|
+
return field ? { field, message } : { message };
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return details;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private resolveRequestId(headers: Record<string, unknown> | undefined): string | undefined {
|
|
119
|
+
if (!headers) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const value = headers['x-request-id'];
|
|
124
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim().length > 0) {
|
|
128
|
+
return value[0];
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private extractField(message: string): string | undefined {
|
|
134
|
+
const match = message.match(/^([A-Za-z0-9_.[\]-]+)\s+/);
|
|
135
|
+
return match?.[1];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface ValidationErrorDetail {
|
|
2
|
+
field?: string;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type AppErrorDetails = ValidationErrorDetail[] | Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export interface AppErrorPayload {
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
status: number;
|
|
12
|
+
details?: AppErrorDetails;
|
|
13
|
+
requestId?: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AppErrorEnvelope {
|
|
18
|
+
error: AppErrorPayload;
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## Error Handling Strategy
|
|
2
|
+
|
|
3
|
+
- `@forgeon/core` owns the global HTTP error envelope.
|
|
4
|
+
- API apps import `CoreErrorsModule` and register `CoreExceptionFilter` as a global filter.
|
|
5
|
+
- Envelope fields:
|
|
6
|
+
- `error.code`
|
|
7
|
+
- `error.message`
|
|
8
|
+
- `error.status`
|
|
9
|
+
- `error.details` (optional, mainly validation context)
|
|
10
|
+
- `error.requestId` (optional, derived from `x-request-id`)
|
|
11
|
+
- `error.timestamp`
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
### Error Handling
|
|
2
|
+
|
|
3
|
+
`core-errors` is enabled by default.
|
|
4
|
+
|
|
5
|
+
- `CoreErrorsModule` is imported in `apps/api/src/app.module.ts`.
|
|
6
|
+
- `CoreExceptionFilter` is registered globally in `apps/api/src/main.ts`.
|
|
7
|
+
- Controllers and services should throw standard Nest exceptions; envelope formatting is handled centrally.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## Error Handling (`core-errors`)
|
|
2
|
+
|
|
3
|
+
`@forgeon/core` includes a default global exception filter (`CoreExceptionFilter`).
|
|
4
|
+
|
|
5
|
+
Wiring:
|
|
6
|
+
- module import: `apps/api/src/app.module.ts` (`CoreErrorsModule`)
|
|
7
|
+
- global registration: `apps/api/src/main.ts` (`app.useGlobalFilters(app.get(CoreExceptionFilter))`)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
- throw standard Nest exceptions in services/controllers:
|
|
11
|
+
- `throw new ConflictException('Email already exists')`
|
|
12
|
+
- `throw new NotFoundException('Resource not found')`
|
|
13
|
+
|
|
14
|
+
Response envelope:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"error": {
|
|
19
|
+
"code": "conflict",
|
|
20
|
+
"message": "Email already exists",
|
|
21
|
+
"status": 409,
|
|
22
|
+
"details": [],
|
|
23
|
+
"requestId": "optional",
|
|
24
|
+
"timestamp": "2026-02-25T12:00:00.000Z"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ArgumentsHost,
|
|
3
|
-
Catch,
|
|
4
|
-
ExceptionFilter,
|
|
5
|
-
HttpException,
|
|
6
|
-
HttpStatus,
|
|
7
|
-
Injectable,
|
|
8
|
-
Optional,
|
|
9
|
-
} from '@nestjs/common';
|
|
10
|
-
import { I18nService } from 'nestjs-i18n';
|
|
11
|
-
import { Request, Response } from 'express';
|
|
12
|
-
|
|
13
|
-
@Injectable()
|
|
14
|
-
@Catch()
|
|
15
|
-
export class AppExceptionFilter implements ExceptionFilter {
|
|
16
|
-
constructor(@Optional() private readonly i18n?: I18nService) {}
|
|
17
|
-
|
|
18
|
-
catch(exception: unknown, host: ArgumentsHost): void {
|
|
19
|
-
const context = host.switchToHttp();
|
|
20
|
-
const response = context.getResponse<Response>();
|
|
21
|
-
const request = context.getRequest<Request>();
|
|
22
|
-
|
|
23
|
-
const status =
|
|
24
|
-
exception instanceof HttpException
|
|
25
|
-
? exception.getStatus()
|
|
26
|
-
: HttpStatus.INTERNAL_SERVER_ERROR;
|
|
27
|
-
|
|
28
|
-
const payload =
|
|
29
|
-
exception instanceof HttpException
|
|
30
|
-
? exception.getResponse()
|
|
31
|
-
: { message: 'Internal server error' };
|
|
32
|
-
|
|
33
|
-
const code = this.resolveCode(status);
|
|
34
|
-
const message = this.resolveMessage(payload, status, request);
|
|
35
|
-
const details = this.resolveDetails(payload);
|
|
36
|
-
|
|
37
|
-
response.status(status).json({
|
|
38
|
-
error: {
|
|
39
|
-
code,
|
|
40
|
-
message,
|
|
41
|
-
...(details !== undefined ? { details } : {}),
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private resolveCode(status: number): string {
|
|
47
|
-
switch (status) {
|
|
48
|
-
case HttpStatus.BAD_REQUEST:
|
|
49
|
-
return 'validation_error';
|
|
50
|
-
case HttpStatus.UNAUTHORIZED:
|
|
51
|
-
return 'unauthorized';
|
|
52
|
-
case HttpStatus.FORBIDDEN:
|
|
53
|
-
return 'forbidden';
|
|
54
|
-
case HttpStatus.NOT_FOUND:
|
|
55
|
-
return 'not_found';
|
|
56
|
-
case HttpStatus.CONFLICT:
|
|
57
|
-
return 'conflict';
|
|
58
|
-
default:
|
|
59
|
-
return 'internal_error';
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
private resolveMessage(
|
|
64
|
-
payload: unknown,
|
|
65
|
-
status: number,
|
|
66
|
-
request: Request,
|
|
67
|
-
): string {
|
|
68
|
-
const lang = this.resolveLang(request);
|
|
69
|
-
|
|
70
|
-
if (status === HttpStatus.NOT_FOUND) {
|
|
71
|
-
return this.translate('errors.notFound', lang);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (typeof payload === 'string') {
|
|
75
|
-
return this.translate(payload, lang);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (typeof payload === 'object' && payload !== null) {
|
|
79
|
-
const obj = payload as { message?: string | string[] };
|
|
80
|
-
if (Array.isArray(obj.message) && obj.message.length > 0) {
|
|
81
|
-
return this.translate(obj.message[0], lang);
|
|
82
|
-
}
|
|
83
|
-
if (typeof obj.message === 'string') {
|
|
84
|
-
return this.translate(obj.message, lang);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return 'Internal server error';
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
private resolveDetails(payload: unknown): unknown {
|
|
92
|
-
if (typeof payload !== 'object' || payload === null) {
|
|
93
|
-
return undefined;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const obj = payload as { message?: string | string[]; error?: string };
|
|
97
|
-
if (Array.isArray(obj.message) && obj.message.length > 1) {
|
|
98
|
-
return obj.message;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
private resolveLang(request: Request): string | undefined {
|
|
105
|
-
const queryLang = request.query?.lang;
|
|
106
|
-
if (typeof queryLang === 'string' && queryLang.length > 0) {
|
|
107
|
-
return queryLang;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const header = request.headers['accept-language'];
|
|
111
|
-
if (typeof header === 'string' && header.length > 0) {
|
|
112
|
-
return header.split(',')[0].trim();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return undefined;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
private translate(keyOrMessage: string, lang?: string): string {
|
|
119
|
-
if (!this.i18n) {
|
|
120
|
-
return keyOrMessage;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const translated = this.i18n.t(keyOrMessage, {
|
|
124
|
-
lang,
|
|
125
|
-
defaultValue: keyOrMessage,
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
return typeof translated === 'string' ? translated : keyOrMessage;
|
|
129
|
-
}
|
|
130
|
-
}
|