create-forgeon 0.3.3 → 0.3.5
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/modules/executor.test.mjs +13 -4
- package/src/modules/logger.mjs +12 -3
- package/src/modules/rate-limit.mjs +2 -0
- package/src/modules/sync-integrations.mjs +81 -14
- package/templates/base/apps/web/src/App.tsx +4 -1
- package/templates/base/scripts/forgeon-sync-integrations.mjs +29 -4
- package/templates/module-presets/i18n/apps/web/src/App.tsx +2 -1
- package/templates/module-presets/logger/packages/logger/src/forgeon-logger.module.ts +4 -5
- package/templates/module-presets/logger/packages/logger/src/http-logging.middleware.ts +74 -0
- package/templates/module-presets/logger/packages/logger/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -70,10 +70,13 @@ function assertRateLimitWiring(projectRoot) {
|
|
|
70
70
|
path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
|
|
71
71
|
'utf8',
|
|
72
72
|
);
|
|
73
|
+
assert.match(healthController, /import \{ Header \} from '@nestjs\/common';/);
|
|
74
|
+
assert.match(healthController, /@Header\('Cache-Control', 'no-store, no-cache, must-revalidate'\)/);
|
|
73
75
|
assert.match(healthController, /@Get\('rate-limit'\)/);
|
|
74
76
|
assert.match(healthController, /TOO_MANY_REQUESTS/);
|
|
75
77
|
|
|
76
78
|
const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
|
|
79
|
+
assert.match(appTsx, /cache: 'no-store'/);
|
|
77
80
|
assert.match(appTsx, /Check rate limit \(click repeatedly\)/);
|
|
78
81
|
assert.match(appTsx, /Rate limit probe response/);
|
|
79
82
|
|
|
@@ -569,10 +572,9 @@ describe('addModule', () => {
|
|
|
569
572
|
|
|
570
573
|
const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
|
|
571
574
|
assert.match(mainTs, /ForgeonLoggerService/);
|
|
572
|
-
assert.match(mainTs, /ForgeonHttpLoggingInterceptor/);
|
|
573
575
|
assert.match(mainTs, /bufferLogs: true/);
|
|
574
576
|
assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
|
|
575
|
-
assert.
|
|
577
|
+
assert.doesNotMatch(mainTs, /useGlobalInterceptors/);
|
|
576
578
|
|
|
577
579
|
const apiDockerfile = fs.readFileSync(
|
|
578
580
|
path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
|
|
@@ -588,6 +590,13 @@ describe('addModule', () => {
|
|
|
588
590
|
);
|
|
589
591
|
assert.match(loggerTsconfig, /"extends": "\.\.\/\.\.\/tsconfig\.base\.node\.json"/);
|
|
590
592
|
|
|
593
|
+
const loggerModule = fs.readFileSync(
|
|
594
|
+
path.join(projectRoot, 'packages', 'logger', 'src', 'forgeon-logger.module.ts'),
|
|
595
|
+
'utf8',
|
|
596
|
+
);
|
|
597
|
+
assert.match(loggerModule, /ForgeonHttpLoggingMiddleware/);
|
|
598
|
+
assert.match(loggerModule, /consumer\.apply\(RequestIdMiddleware, ForgeonHttpLoggingMiddleware\)\.forRoutes\('\*'\);/);
|
|
599
|
+
|
|
591
600
|
const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
|
|
592
601
|
assert.match(apiEnv, /LOGGER_LEVEL=log/);
|
|
593
602
|
assert.match(apiEnv, /LOGGER_HTTP_ENABLED=true/);
|
|
@@ -954,7 +963,7 @@ describe('addModule', () => {
|
|
|
954
963
|
|
|
955
964
|
const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
|
|
956
965
|
assert.match(mainTs, /ForgeonLoggerService/);
|
|
957
|
-
assert.
|
|
966
|
+
assert.doesNotMatch(mainTs, /ForgeonHttpLoggingInterceptor/);
|
|
958
967
|
} finally {
|
|
959
968
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
960
969
|
}
|
|
@@ -1055,7 +1064,7 @@ describe('addModule', () => {
|
|
|
1055
1064
|
const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
|
|
1056
1065
|
assert.match(mainTs, /setupSwagger\(app,\s*swaggerConfigService\)/);
|
|
1057
1066
|
assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
|
|
1058
|
-
assert.
|
|
1067
|
+
assert.doesNotMatch(mainTs, /useGlobalInterceptors/);
|
|
1059
1068
|
|
|
1060
1069
|
const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
|
|
1061
1070
|
assert.match(apiPackage, /@forgeon\/swagger/);
|
package/src/modules/logger.mjs
CHANGED
|
@@ -44,10 +44,14 @@ function patchMain(targetRoot) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
47
|
+
content = content.replace(
|
|
48
|
+
"import { ForgeonHttpLoggingInterceptor, ForgeonLoggerService } from '@forgeon/logger';",
|
|
49
|
+
"import { ForgeonLoggerService } from '@forgeon/logger';",
|
|
50
|
+
);
|
|
47
51
|
content = ensureLineBefore(
|
|
48
52
|
content,
|
|
49
53
|
"import { NestFactory } from '@nestjs/core';",
|
|
50
|
-
"import {
|
|
54
|
+
"import { ForgeonLoggerService } from '@forgeon/logger';",
|
|
51
55
|
);
|
|
52
56
|
|
|
53
57
|
content = content.replace(
|
|
@@ -55,12 +59,16 @@ function patchMain(targetRoot) {
|
|
|
55
59
|
'const app = await NestFactory.create(AppModule, { bufferLogs: true });',
|
|
56
60
|
);
|
|
57
61
|
|
|
62
|
+
content = content.replace(
|
|
63
|
+
/\n\s*app\.useGlobalInterceptors\(app\.get\(ForgeonHttpLoggingInterceptor\)\);\s*/g,
|
|
64
|
+
'\n',
|
|
65
|
+
);
|
|
66
|
+
|
|
58
67
|
if (!content.includes('app.useLogger(app.get(ForgeonLoggerService));')) {
|
|
59
68
|
content = content.replace(
|
|
60
69
|
' const coreConfigService = app.get(CoreConfigService);',
|
|
61
70
|
` const coreConfigService = app.get(CoreConfigService);
|
|
62
|
-
app.useLogger(app.get(ForgeonLoggerService))
|
|
63
|
-
app.useGlobalInterceptors(app.get(ForgeonHttpLoggingInterceptor));`,
|
|
71
|
+
app.useLogger(app.get(ForgeonLoggerService));`,
|
|
64
72
|
);
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -179,6 +187,7 @@ function patchReadme(targetRoot) {
|
|
|
179
187
|
The logger add-module provides:
|
|
180
188
|
- request id middleware (default header: \`x-request-id\`)
|
|
181
189
|
- HTTP access logs with method/path/status/duration/ip/requestId
|
|
190
|
+
- HTTP access logs are emitted from middleware, so requests rejected by guards (for example 429 from rate-limit) are still logged
|
|
182
191
|
- Nest logger integration via \`app.useLogger(...)\`
|
|
183
192
|
|
|
184
193
|
It installs independently and intentionally does not add a dedicated API/web probe.
|
|
@@ -120,8 +120,10 @@ function patchHealthController(targetRoot) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
123
|
+
content = ensureImportLine(content, "import { Header } from '@nestjs/common';");
|
|
123
124
|
if (!content.includes("@Get('rate-limit')")) {
|
|
124
125
|
const method = `
|
|
126
|
+
@Header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
|
125
127
|
@Get('rate-limit')
|
|
126
128
|
getRateLimitProbe() {
|
|
127
129
|
return {
|
|
@@ -24,6 +24,25 @@ const PRISMA_AUTH_MIGRATION_TEMPLATE = path.join(
|
|
|
24
24
|
'0002_auth_refresh_token_hash',
|
|
25
25
|
);
|
|
26
26
|
|
|
27
|
+
const AUTH_PERSISTENCE_STRATEGIES = [
|
|
28
|
+
{
|
|
29
|
+
id: 'db-prisma',
|
|
30
|
+
capability: 'db-adapter',
|
|
31
|
+
providerLabel: 'db-prisma',
|
|
32
|
+
participants: ['jwt-auth', 'db-adapter'],
|
|
33
|
+
relatedModules: ['jwt-auth', 'db-prisma'],
|
|
34
|
+
description: [
|
|
35
|
+
'Patch AppModule to wire AUTH_REFRESH_TOKEN_STORE to the current db-adapter implementation (today: PrismaAuthRefreshTokenStore)',
|
|
36
|
+
'Add apps/api/src/auth/prisma-auth-refresh-token.store.ts',
|
|
37
|
+
'Extend Prisma User model with refreshTokenHash and add migration 0002_auth_refresh_token_hash',
|
|
38
|
+
'Update JWT auth README note to reflect db-adapter-backed refresh-token persistence',
|
|
39
|
+
],
|
|
40
|
+
isDetected: (detected) => detected.dbPrisma,
|
|
41
|
+
isPending: isAuthPersistencePending,
|
|
42
|
+
apply: syncJwtDbPrisma,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
27
46
|
function ensureLineAfter(content, anchorLine, lineToInsert) {
|
|
28
47
|
if (content.includes(lineToInsert)) {
|
|
29
48
|
return content;
|
|
@@ -97,15 +116,10 @@ const INTEGRATION_GROUPS = [
|
|
|
97
116
|
title: 'Auth Persistence Integration',
|
|
98
117
|
participants: ['jwt-auth', 'db-adapter'],
|
|
99
118
|
relatedModules: ['jwt-auth', 'db-prisma'],
|
|
100
|
-
description:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
'Update JWT auth README note to reflect db-adapter-backed refresh-token persistence',
|
|
105
|
-
],
|
|
106
|
-
isAvailable: (detected) => detected.jwtAuth && detected.dbPrisma,
|
|
107
|
-
isPending: (rootDir) => isAuthPersistencePending(rootDir),
|
|
108
|
-
apply: syncJwtDbPrisma,
|
|
119
|
+
description: (detected) => getAuthPersistenceDescription(detected),
|
|
120
|
+
isAvailable: (detected) => detected.jwtAuth && hasSingleAuthPersistenceStrategy(detected),
|
|
121
|
+
isPending: (rootDir, detected) => isAuthPersistencePendingForDetected(rootDir, detected),
|
|
122
|
+
apply: applyAuthPersistenceSync,
|
|
109
123
|
},
|
|
110
124
|
{
|
|
111
125
|
id: 'auth-rbac-claims',
|
|
@@ -141,6 +155,52 @@ function detectModules(rootDir) {
|
|
|
141
155
|
};
|
|
142
156
|
}
|
|
143
157
|
|
|
158
|
+
function resolveAuthPersistenceStrategy(detected) {
|
|
159
|
+
const matched = AUTH_PERSISTENCE_STRATEGIES.filter((strategy) => strategy.isDetected(detected));
|
|
160
|
+
if (matched.length === 0) {
|
|
161
|
+
return { kind: 'none' };
|
|
162
|
+
}
|
|
163
|
+
if (matched.length > 1) {
|
|
164
|
+
return { kind: 'conflict', strategies: matched };
|
|
165
|
+
}
|
|
166
|
+
return { kind: 'single', strategy: matched[0] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function hasSingleAuthPersistenceStrategy(detected) {
|
|
170
|
+
return resolveAuthPersistenceStrategy(detected).kind === 'single';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getAuthPersistenceDescription(detected) {
|
|
174
|
+
const resolved = resolveAuthPersistenceStrategy(detected);
|
|
175
|
+
if (resolved.kind === 'single') {
|
|
176
|
+
return [...resolved.strategy.description];
|
|
177
|
+
}
|
|
178
|
+
return [
|
|
179
|
+
'Use the current db-adapter provider strategy to wire refresh-token persistence.',
|
|
180
|
+
'A supported db-adapter provider must be installed before this integration can apply.',
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isAuthPersistencePendingForDetected(rootDir, detected) {
|
|
185
|
+
const resolved = resolveAuthPersistenceStrategy(detected);
|
|
186
|
+
if (resolved.kind !== 'single') {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return resolved.strategy.isPending(rootDir);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function applyAuthPersistenceSync({ rootDir, packageRoot, changedFiles }) {
|
|
193
|
+
const detected = detectModules(rootDir);
|
|
194
|
+
const resolved = resolveAuthPersistenceStrategy(detected);
|
|
195
|
+
if (resolved.kind === 'none') {
|
|
196
|
+
return { applied: false, reason: 'no supported db-adapter provider detected' };
|
|
197
|
+
}
|
|
198
|
+
if (resolved.kind === 'conflict') {
|
|
199
|
+
return { applied: false, reason: 'multiple db-adapter providers detected' };
|
|
200
|
+
}
|
|
201
|
+
return resolved.strategy.apply({ rootDir, packageRoot, changedFiles });
|
|
202
|
+
}
|
|
203
|
+
|
|
144
204
|
function getGroupParticipants(group) {
|
|
145
205
|
return Array.isArray(group.participants) && group.participants.length > 0
|
|
146
206
|
? group.participants
|
|
@@ -155,6 +215,13 @@ function getGroupRelatedModules(group) {
|
|
|
155
215
|
: getGroupParticipants(group);
|
|
156
216
|
}
|
|
157
217
|
|
|
218
|
+
function getGroupDescription(group, detected) {
|
|
219
|
+
if (typeof group.description === 'function') {
|
|
220
|
+
return group.description(detected);
|
|
221
|
+
}
|
|
222
|
+
return Array.isArray(group.description) ? group.description : [];
|
|
223
|
+
}
|
|
224
|
+
|
|
158
225
|
function syncJwtDbPrisma({ rootDir, packageRoot, changedFiles }) {
|
|
159
226
|
const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
|
|
160
227
|
const schemaPath = path.join(rootDir, 'apps', 'api', 'prisma', 'schema.prisma');
|
|
@@ -381,7 +448,7 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
|
|
|
381
448
|
const detected = detectModules(rootDir);
|
|
382
449
|
const summary = [];
|
|
383
450
|
const available = INTEGRATION_GROUPS.filter(
|
|
384
|
-
(group) => group.isAvailable(detected) && group.isPending(rootDir),
|
|
451
|
+
(group) => group.isAvailable(detected) && group.isPending(rootDir, detected),
|
|
385
452
|
);
|
|
386
453
|
const selected = Array.isArray(groupIds)
|
|
387
454
|
? available.filter((group) => groupIds.includes(group.id))
|
|
@@ -391,7 +458,7 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
|
|
|
391
458
|
summary.push({
|
|
392
459
|
id: group.id,
|
|
393
460
|
title: group.title,
|
|
394
|
-
modules: group
|
|
461
|
+
modules: getGroupParticipants(group),
|
|
395
462
|
result: group.apply({ rootDir, packageRoot, changedFiles }),
|
|
396
463
|
});
|
|
397
464
|
}
|
|
@@ -402,7 +469,7 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
|
|
|
402
469
|
id: group.id,
|
|
403
470
|
title: group.title,
|
|
404
471
|
modules: [...getGroupParticipants(group)],
|
|
405
|
-
description: [...group
|
|
472
|
+
description: [...getGroupDescription(group, detected)],
|
|
406
473
|
})),
|
|
407
474
|
changedFiles: [...changedFiles].sort().map((filePath) => path.relative(rootDir, filePath)),
|
|
408
475
|
};
|
|
@@ -414,7 +481,7 @@ export function scanIntegrations({ targetRoot, relatedModuleId = null }) {
|
|
|
414
481
|
const available = INTEGRATION_GROUPS.filter(
|
|
415
482
|
(group) =>
|
|
416
483
|
group.isAvailable(detected) &&
|
|
417
|
-
group.isPending(rootDir) &&
|
|
484
|
+
group.isPending(rootDir, detected) &&
|
|
418
485
|
(!relatedModuleId || getGroupRelatedModules(group).includes(relatedModuleId)),
|
|
419
486
|
);
|
|
420
487
|
return {
|
|
@@ -422,7 +489,7 @@ export function scanIntegrations({ targetRoot, relatedModuleId = null }) {
|
|
|
422
489
|
id: group.id,
|
|
423
490
|
title: group.title,
|
|
424
491
|
modules: [...getGroupParticipants(group)],
|
|
425
|
-
description: [...group
|
|
492
|
+
description: [...getGroupDescription(group, detected)],
|
|
426
493
|
})),
|
|
427
494
|
};
|
|
428
495
|
}
|
|
@@ -13,7 +13,10 @@ export default function App() {
|
|
|
13
13
|
const [networkError, setNetworkError] = useState<string | null>(null);
|
|
14
14
|
|
|
15
15
|
const requestProbe = async (url: string, init?: RequestInit): Promise<ProbeResult> => {
|
|
16
|
-
const response = await fetch(url,
|
|
16
|
+
const response = await fetch(url, {
|
|
17
|
+
...(init ?? {}),
|
|
18
|
+
cache: 'no-store',
|
|
19
|
+
});
|
|
17
20
|
let body: unknown = null;
|
|
18
21
|
|
|
19
22
|
try {
|
|
@@ -45,6 +45,15 @@ ALTER TABLE "User"
|
|
|
45
45
|
ADD COLUMN "refreshTokenHash" TEXT;
|
|
46
46
|
`;
|
|
47
47
|
|
|
48
|
+
const AUTH_PERSISTENCE_STRATEGIES = [
|
|
49
|
+
{
|
|
50
|
+
id: 'db-prisma',
|
|
51
|
+
providerLabel: 'db-prisma',
|
|
52
|
+
isDetected: (detected) => detected.dbPrisma,
|
|
53
|
+
apply: syncJwtDbPrisma,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
48
57
|
function detectModules(rootDir) {
|
|
49
58
|
const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
|
|
50
59
|
const appModuleText = fs.existsSync(appModulePath) ? fs.readFileSync(appModulePath, 'utf8') : '';
|
|
@@ -74,6 +83,17 @@ function ensureLineAfter(content, anchorLine, lineToInsert) {
|
|
|
74
83
|
return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
function resolveAuthPersistenceStrategy(detected) {
|
|
87
|
+
const matched = AUTH_PERSISTENCE_STRATEGIES.filter((strategy) => strategy.isDetected(detected));
|
|
88
|
+
if (matched.length === 0) {
|
|
89
|
+
return { kind: 'none' };
|
|
90
|
+
}
|
|
91
|
+
if (matched.length > 1) {
|
|
92
|
+
return { kind: 'conflict', strategies: matched };
|
|
93
|
+
}
|
|
94
|
+
return { kind: 'single', strategy: matched[0] };
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
function syncJwtDbPrisma({ rootDir, changedFiles }) {
|
|
78
98
|
const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
|
|
79
99
|
const schemaPath = path.join(rootDir, 'apps', 'api', 'prisma', 'schema.prisma');
|
|
@@ -290,16 +310,21 @@ function run() {
|
|
|
290
310
|
const changedFiles = new Set();
|
|
291
311
|
const detected = detectModules(rootDir);
|
|
292
312
|
const summary = [];
|
|
313
|
+
const authPersistence = resolveAuthPersistenceStrategy(detected);
|
|
293
314
|
|
|
294
|
-
if (detected.jwtAuth &&
|
|
315
|
+
if (detected.jwtAuth && authPersistence.kind === 'single') {
|
|
295
316
|
summary.push({
|
|
296
|
-
feature:
|
|
297
|
-
result:
|
|
317
|
+
feature: `jwt-auth + db-adapter (current provider: ${authPersistence.strategy.providerLabel})`,
|
|
318
|
+
result: authPersistence.strategy.apply({ rootDir, changedFiles }),
|
|
298
319
|
});
|
|
299
320
|
} else {
|
|
321
|
+
const reason =
|
|
322
|
+
authPersistence.kind === 'conflict'
|
|
323
|
+
? 'multiple db-adapter providers detected'
|
|
324
|
+
: 'required components are not both available';
|
|
300
325
|
summary.push({
|
|
301
326
|
feature: 'jwt-auth + db-adapter (current provider: db-prisma)',
|
|
302
|
-
result: { applied: false, reason
|
|
327
|
+
result: { applied: false, reason },
|
|
303
328
|
});
|
|
304
329
|
}
|
|
305
330
|
|
|
@@ -27,7 +27,8 @@ export default function App() {
|
|
|
27
27
|
|
|
28
28
|
const requestProbe = async (path: string, init?: RequestInit): Promise<ProbeResult> => {
|
|
29
29
|
const response = await fetch(`/api${path}${toLangQuery(locale)}`, {
|
|
30
|
-
...init,
|
|
30
|
+
...(init ?? {}),
|
|
31
|
+
cache: 'no-store',
|
|
31
32
|
headers: {
|
|
32
33
|
...(init?.headers ?? {}),
|
|
33
34
|
'Accept-Language': locale,
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
|
2
|
-
import {
|
|
2
|
+
import { ForgeonHttpLoggingMiddleware } from './http-logging.middleware';
|
|
3
3
|
import { ForgeonLoggerService } from './forgeon-logger.service';
|
|
4
4
|
import { LoggerConfigModule } from './logger-config.module';
|
|
5
5
|
import { RequestIdMiddleware } from './request-id.middleware';
|
|
6
6
|
|
|
7
7
|
@Module({
|
|
8
8
|
imports: [LoggerConfigModule],
|
|
9
|
-
providers: [RequestIdMiddleware,
|
|
10
|
-
exports: [LoggerConfigModule, ForgeonLoggerService
|
|
9
|
+
providers: [RequestIdMiddleware, ForgeonHttpLoggingMiddleware, ForgeonLoggerService],
|
|
10
|
+
exports: [LoggerConfigModule, ForgeonLoggerService],
|
|
11
11
|
})
|
|
12
12
|
export class ForgeonLoggerModule implements NestModule {
|
|
13
13
|
configure(consumer: MiddlewareConsumer): void {
|
|
14
|
-
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
|
14
|
+
consumer.apply(RequestIdMiddleware, ForgeonHttpLoggingMiddleware).forRoutes('*');
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
2
|
+
import { ForgeonLoggerService } from './forgeon-logger.service';
|
|
3
|
+
import { LoggerConfigService } from './logger-config.service';
|
|
4
|
+
|
|
5
|
+
type HeaderValue = string | string[] | undefined;
|
|
6
|
+
type HeadersRecord = Record<string, HeaderValue>;
|
|
7
|
+
|
|
8
|
+
interface RequestLike {
|
|
9
|
+
method?: string;
|
|
10
|
+
originalUrl?: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
ip?: string;
|
|
13
|
+
requestId?: string;
|
|
14
|
+
headers?: HeadersRecord;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ResponseLike {
|
|
18
|
+
statusCode?: number;
|
|
19
|
+
once?: (event: string, listener: () => void) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type NextFunction = () => void;
|
|
23
|
+
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class ForgeonHttpLoggingMiddleware implements NestMiddleware {
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly logger: ForgeonLoggerService,
|
|
28
|
+
private readonly loggerConfig: LoggerConfigService,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
use(request: RequestLike, response: ResponseLike, next: NextFunction): void {
|
|
32
|
+
if (!this.loggerConfig.httpEnabled) {
|
|
33
|
+
next();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const method = request.method ?? 'UNKNOWN';
|
|
38
|
+
const path = request.originalUrl ?? request.url ?? '/';
|
|
39
|
+
const ip = request.ip;
|
|
40
|
+
const requestId =
|
|
41
|
+
request.requestId ?? this.readHeader(request.headers, this.loggerConfig.requestIdHeader);
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
|
|
44
|
+
if (typeof response.once === 'function') {
|
|
45
|
+
response.once('finish', () => {
|
|
46
|
+
this.logger.logHttpRequest({
|
|
47
|
+
method,
|
|
48
|
+
path,
|
|
49
|
+
statusCode: response.statusCode ?? 200,
|
|
50
|
+
durationMs: Date.now() - startedAt,
|
|
51
|
+
requestId,
|
|
52
|
+
ip,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
next();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private readHeader(headers: HeadersRecord | undefined, name: string): string | undefined {
|
|
61
|
+
if (!headers) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const value = headers[name.toLowerCase()];
|
|
66
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim().length > 0) {
|
|
70
|
+
return value[0];
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -3,7 +3,7 @@ export * from './logger-config.loader';
|
|
|
3
3
|
export * from './logger-config.service';
|
|
4
4
|
export * from './logger-config.module';
|
|
5
5
|
export * from './forgeon-logger.service';
|
|
6
|
+
export * from './http-logging.middleware';
|
|
6
7
|
export * from './http-logging.interceptor';
|
|
7
8
|
export * from './request-id.middleware';
|
|
8
9
|
export * from './forgeon-logger.module';
|
|
9
|
-
|