create-forgeon 0.1.22 → 0.1.23

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -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'),
@@ -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);
@@ -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
- providers: [AppExceptionFilter],
131
- })
132
- export class AppModule {}
133
- `,
134
- 'utf8',
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
- const filterPath = path.join(
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
- providers: [AppExceptionFilter],
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
- import { AppExceptionFilter } from './common/filters/app-exception.filter';
7
-
8
- async function bootstrap() {
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(AppExceptionFilter));
16
+ transform: true,
17
+ }),
18
+ );
19
+ app.useGlobalFilters(app.get(CoreExceptionFilter));
21
20
 
22
21
  await app.listen(coreConfigService.port);
23
22
  }
@@ -5,4 +5,5 @@ Shared backend core package.
5
5
  Current submodules:
6
6
 
7
7
  - `core-config` - Zod-validated env config + typed accessors for API runtime.
8
+ - `core-errors` - global exception filter + stable error envelope shape.
8
9
 
@@ -0,0 +1,8 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { CoreExceptionFilter } from './core-exception.filter';
3
+
4
+ @Module({
5
+ providers: [CoreExceptionFilter],
6
+ exports: [CoreExceptionFilter],
7
+ })
8
+ export class CoreErrorsModule {}
@@ -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,3 @@
1
+ export * from './error.types';
2
+ export * from './core-exception.filter';
3
+ export * from './core-errors.module';
@@ -1 +1,2 @@
1
1
  export * from './config';
2
+ export * from './errors';
@@ -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
- }