create-forgeon 0.1.32 → 0.1.33

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.32",
3
+ "version": "0.1.33",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -4,6 +4,7 @@ import { ensureModuleExists } from './registry.mjs';
4
4
  import { writeModuleDocs } from './docs.mjs';
5
5
  import { applyI18nModule } from './i18n.mjs';
6
6
  import { applyLoggerModule } from './logger.mjs';
7
+ import { applySwaggerModule } from './swagger.mjs';
7
8
 
8
9
  function ensureForgeonLikeProject(targetRoot) {
9
10
  const requiredPaths = [
@@ -24,6 +25,7 @@ function ensureForgeonLikeProject(targetRoot) {
24
25
  const MODULE_APPLIERS = {
25
26
  i18n: applyI18nModule,
26
27
  logger: applyLoggerModule,
28
+ swagger: applySwaggerModule,
27
29
  };
28
30
 
29
31
  export function applyModulePreset({ moduleId, targetRoot, packageRoot }) {
@@ -335,4 +335,135 @@ describe('addModule', () => {
335
335
  fs.rmSync(targetRoot, { recursive: true, force: true });
336
336
  }
337
337
  });
338
+
339
+ it('applies swagger module on top of scaffold without i18n', () => {
340
+ const targetRoot = mkTmp('forgeon-module-swagger-');
341
+ const projectRoot = path.join(targetRoot, 'demo-swagger');
342
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
343
+
344
+ try {
345
+ scaffoldProject({
346
+ templateRoot,
347
+ packageRoot,
348
+ targetRoot: projectRoot,
349
+ projectName: 'demo-swagger',
350
+ frontend: 'react',
351
+ db: 'prisma',
352
+ i18nEnabled: false,
353
+ proxy: 'caddy',
354
+ });
355
+
356
+ const result = addModule({
357
+ moduleId: 'swagger',
358
+ targetRoot: projectRoot,
359
+ packageRoot,
360
+ });
361
+
362
+ assert.equal(result.applied, true);
363
+ assert.match(result.message, /applied/);
364
+ assert.equal(
365
+ fs.existsSync(path.join(projectRoot, 'packages', 'swagger', 'package.json')),
366
+ true,
367
+ );
368
+
369
+ const swaggerTsconfig = fs.readFileSync(
370
+ path.join(projectRoot, 'packages', 'swagger', 'tsconfig.json'),
371
+ 'utf8',
372
+ );
373
+ assert.match(swaggerTsconfig, /"extends": "\.\.\/\.\.\/tsconfig\.base\.node\.json"/);
374
+
375
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
376
+ assert.match(apiPackage, /@forgeon\/swagger/);
377
+ assert.match(apiPackage, /pnpm --filter @forgeon\/swagger build/);
378
+
379
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
380
+ assert.match(appModule, /@forgeon\/swagger/);
381
+ assert.match(appModule, /swaggerConfig/);
382
+ assert.match(appModule, /swaggerEnvSchema/);
383
+ assert.match(appModule, /ForgeonSwaggerModule/);
384
+
385
+ const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
386
+ assert.match(mainTs, /setupSwagger/);
387
+ assert.match(mainTs, /SwaggerConfigService/);
388
+ assert.match(mainTs, /setupSwagger\(app,\s*swaggerConfigService\)/);
389
+
390
+ const apiDockerfile = fs.readFileSync(
391
+ path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
392
+ 'utf8',
393
+ );
394
+ assert.match(apiDockerfile, /COPY packages\/swagger\/package\.json packages\/swagger\/package\.json/);
395
+ assert.match(apiDockerfile, /COPY packages\/swagger packages\/swagger/);
396
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/swagger build/);
397
+
398
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
399
+ assert.match(apiEnv, /SWAGGER_ENABLED=false/);
400
+ assert.match(apiEnv, /SWAGGER_PATH=docs/);
401
+ assert.match(apiEnv, /SWAGGER_TITLE="Forgeon API"/);
402
+ assert.match(apiEnv, /SWAGGER_VERSION=1\.0\.0/);
403
+
404
+ const dockerEnv = fs.readFileSync(
405
+ path.join(projectRoot, 'infra', 'docker', '.env.example'),
406
+ 'utf8',
407
+ );
408
+ assert.match(dockerEnv, /SWAGGER_ENABLED=false/);
409
+ assert.match(dockerEnv, /SWAGGER_PATH=docs/);
410
+ assert.match(dockerEnv, /SWAGGER_TITLE="Forgeon API"/);
411
+ assert.match(dockerEnv, /SWAGGER_VERSION=1\.0\.0/);
412
+
413
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
414
+ assert.match(compose, /SWAGGER_ENABLED: \$\{SWAGGER_ENABLED\}/);
415
+ assert.match(compose, /SWAGGER_PATH: \$\{SWAGGER_PATH\}/);
416
+ assert.match(compose, /SWAGGER_TITLE: \$\{SWAGGER_TITLE\}/);
417
+ assert.match(compose, /SWAGGER_VERSION: \$\{SWAGGER_VERSION\}/);
418
+
419
+ const rootReadme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
420
+ assert.match(rootReadme, /## Swagger \/ OpenAPI Module/);
421
+ assert.match(rootReadme, /SWAGGER_ENABLED=false/);
422
+ assert.match(rootReadme, /localhost:3000\/docs/);
423
+
424
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
425
+ assert.match(moduleDoc, /Swagger \/ OpenAPI/);
426
+ assert.match(moduleDoc, /Status: implemented/);
427
+ } finally {
428
+ fs.rmSync(targetRoot, { recursive: true, force: true });
429
+ }
430
+ });
431
+
432
+ it('applies swagger module on top of scaffold with i18n', () => {
433
+ const targetRoot = mkTmp('forgeon-module-swagger-i18n-');
434
+ const projectRoot = path.join(targetRoot, 'demo-swagger-i18n');
435
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
436
+
437
+ try {
438
+ scaffoldProject({
439
+ templateRoot,
440
+ packageRoot,
441
+ targetRoot: projectRoot,
442
+ projectName: 'demo-swagger-i18n',
443
+ frontend: 'react',
444
+ db: 'prisma',
445
+ i18nEnabled: true,
446
+ proxy: 'caddy',
447
+ });
448
+
449
+ const result = addModule({
450
+ moduleId: 'swagger',
451
+ targetRoot: projectRoot,
452
+ packageRoot,
453
+ });
454
+
455
+ assert.equal(result.applied, true);
456
+
457
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
458
+ assert.match(appModule, /load: \[coreConfig,\s*dbPrismaConfig,\s*i18nConfig,\s*swaggerConfig\]/);
459
+ assert.match(
460
+ appModule,
461
+ /validate: createEnvValidator\(\[coreEnvSchema,\s*dbPrismaEnvSchema,\s*i18nEnvSchema,\s*swaggerEnvSchema\]\)/,
462
+ );
463
+ assert.match(appModule, /ForgeonSwaggerModule/);
464
+ assert.match(appModule, /ForgeonI18nModule/);
465
+ } finally {
466
+ fs.rmSync(targetRoot, { recursive: true, force: true });
467
+ }
468
+ });
338
469
  });
@@ -15,6 +15,14 @@ const MODULE_PRESETS = {
15
15
  description: 'Structured API logger with request id middleware and HTTP logging interceptor.',
16
16
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
17
17
  },
18
+ swagger: {
19
+ id: 'swagger',
20
+ label: 'Swagger / OpenAPI',
21
+ category: 'api-documentation',
22
+ implemented: true,
23
+ description: 'OpenAPI docs setup with env-based toggle and route path.',
24
+ docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
25
+ },
18
26
  'jwt-auth': {
19
27
  id: 'jwt-auth',
20
28
  label: 'JWT Auth',
@@ -0,0 +1,321 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+
5
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
6
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'swagger', relativePath);
7
+ if (!fs.existsSync(source)) {
8
+ throw new Error(`Missing swagger preset template: ${source}`);
9
+ }
10
+ const destination = path.join(targetRoot, relativePath);
11
+ copyRecursive(source, destination);
12
+ }
13
+
14
+ function ensureDependency(packageJson, name, version) {
15
+ if (!packageJson.dependencies) {
16
+ packageJson.dependencies = {};
17
+ }
18
+ packageJson.dependencies[name] = version;
19
+ }
20
+
21
+ function ensureLineAfter(content, anchorLine, lineToInsert) {
22
+ if (content.includes(lineToInsert)) {
23
+ return content;
24
+ }
25
+
26
+ const index = content.indexOf(anchorLine);
27
+ if (index < 0) {
28
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
29
+ }
30
+
31
+ const insertAt = index + anchorLine.length;
32
+ return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
33
+ }
34
+
35
+ function ensureLineBefore(content, anchorLine, lineToInsert) {
36
+ if (content.includes(lineToInsert)) {
37
+ return content;
38
+ }
39
+
40
+ const index = content.indexOf(anchorLine);
41
+ if (index < 0) {
42
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
43
+ }
44
+
45
+ return `${content.slice(0, index)}${lineToInsert}\n${content.slice(index)}`;
46
+ }
47
+
48
+ function upsertEnvLines(filePath, lines) {
49
+ let content = '';
50
+ if (fs.existsSync(filePath)) {
51
+ content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
52
+ }
53
+
54
+ const keys = new Set(
55
+ content
56
+ .split('\n')
57
+ .filter(Boolean)
58
+ .map((line) => line.split('=')[0]),
59
+ );
60
+
61
+ const append = [];
62
+ for (const line of lines) {
63
+ const key = line.split('=')[0];
64
+ if (!keys.has(key)) {
65
+ append.push(line);
66
+ }
67
+ }
68
+
69
+ const next =
70
+ append.length > 0 ? `${content.trimEnd()}\n${append.join('\n')}\n` : `${content.trimEnd()}\n`;
71
+ fs.writeFileSync(filePath, next.replace(/^\n/, ''), 'utf8');
72
+ }
73
+
74
+ function ensureBuildStep(packageJson, buildCommand) {
75
+ if (!packageJson.scripts) {
76
+ packageJson.scripts = {};
77
+ }
78
+
79
+ const current = packageJson.scripts.predev;
80
+ if (typeof current !== 'string' || current.trim().length === 0) {
81
+ packageJson.scripts.predev = buildCommand;
82
+ return;
83
+ }
84
+
85
+ if (current.includes(buildCommand)) {
86
+ return;
87
+ }
88
+
89
+ packageJson.scripts.predev = `${buildCommand} && ${current}`;
90
+ }
91
+
92
+ function ensureLoadItem(content, itemName) {
93
+ const pattern = /load:\s*\[([^\]]*)\]/m;
94
+ const match = content.match(pattern);
95
+ if (!match) {
96
+ return content;
97
+ }
98
+
99
+ const rawList = match[1];
100
+ const items = rawList
101
+ .split(',')
102
+ .map((item) => item.trim())
103
+ .filter(Boolean);
104
+
105
+ if (!items.includes(itemName)) {
106
+ items.push(itemName);
107
+ }
108
+
109
+ const next = `load: [${items.join(', ')}]`;
110
+ return content.replace(pattern, next);
111
+ }
112
+
113
+ function ensureValidatorSchema(content, schemaName) {
114
+ const pattern = /validate:\s*createEnvValidator\(\[([^\]]*)\]\)/m;
115
+ const match = content.match(pattern);
116
+ if (!match) {
117
+ return content;
118
+ }
119
+
120
+ const rawList = match[1];
121
+ const items = rawList
122
+ .split(',')
123
+ .map((item) => item.trim())
124
+ .filter(Boolean);
125
+
126
+ if (!items.includes(schemaName)) {
127
+ items.push(schemaName);
128
+ }
129
+
130
+ const next = `validate: createEnvValidator([${items.join(', ')}])`;
131
+ return content.replace(pattern, next);
132
+ }
133
+
134
+ function patchApiPackage(targetRoot) {
135
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
136
+ if (!fs.existsSync(packagePath)) {
137
+ return;
138
+ }
139
+
140
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
141
+ ensureDependency(packageJson, '@forgeon/swagger', 'workspace:*');
142
+ ensureBuildStep(packageJson, 'pnpm --filter @forgeon/swagger build');
143
+ writeJson(packagePath, packageJson);
144
+ }
145
+
146
+ function patchMain(targetRoot) {
147
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'main.ts');
148
+ if (!fs.existsSync(filePath)) {
149
+ return;
150
+ }
151
+
152
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
153
+ content = ensureLineBefore(
154
+ content,
155
+ "import { NestFactory } from '@nestjs/core';",
156
+ "import { setupSwagger, SwaggerConfigService } from '@forgeon/swagger';",
157
+ );
158
+
159
+ if (!content.includes('const swaggerConfigService = app.get(SwaggerConfigService);')) {
160
+ content = content.replace(
161
+ ' const coreConfigService = app.get(CoreConfigService);',
162
+ ` const coreConfigService = app.get(CoreConfigService);
163
+ const swaggerConfigService = app.get(SwaggerConfigService);`,
164
+ );
165
+ }
166
+
167
+ if (!content.includes('setupSwagger(app, swaggerConfigService);')) {
168
+ content = content.replace(
169
+ ' app.useGlobalFilters(app.get(CoreExceptionFilter));',
170
+ ` app.useGlobalFilters(app.get(CoreExceptionFilter));
171
+ setupSwagger(app, swaggerConfigService);`,
172
+ );
173
+ }
174
+
175
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
176
+ }
177
+
178
+ function patchAppModule(targetRoot) {
179
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
180
+ if (!fs.existsSync(filePath)) {
181
+ return;
182
+ }
183
+
184
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
185
+ if (!content.includes("from '@forgeon/swagger';")) {
186
+ if (content.includes("import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';")) {
187
+ content = ensureLineAfter(
188
+ content,
189
+ "import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';",
190
+ "import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';",
191
+ );
192
+ } else if (
193
+ content.includes("import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';")
194
+ ) {
195
+ content = ensureLineAfter(
196
+ content,
197
+ "import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
198
+ "import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';",
199
+ );
200
+ }
201
+ }
202
+
203
+ content = ensureLoadItem(content, 'swaggerConfig');
204
+ content = ensureValidatorSchema(content, 'swaggerEnvSchema');
205
+
206
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonSwaggerModule,');
207
+
208
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
209
+ }
210
+
211
+ function patchApiDockerfile(targetRoot) {
212
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
213
+ if (!fs.existsSync(dockerfilePath)) {
214
+ return;
215
+ }
216
+
217
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
218
+
219
+ const packageAnchor = content.includes('COPY packages/logger/package.json packages/logger/package.json')
220
+ ? 'COPY packages/logger/package.json packages/logger/package.json'
221
+ : 'COPY packages/db-prisma/package.json packages/db-prisma/package.json';
222
+ content = ensureLineAfter(
223
+ content,
224
+ packageAnchor,
225
+ 'COPY packages/swagger/package.json packages/swagger/package.json',
226
+ );
227
+
228
+ const sourceAnchor = content.includes('COPY packages/logger packages/logger')
229
+ ? 'COPY packages/logger packages/logger'
230
+ : 'COPY packages/db-prisma packages/db-prisma';
231
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/swagger packages/swagger');
232
+
233
+ content = content.replace(/^RUN pnpm --filter @forgeon\/swagger build\r?\n?/gm, '');
234
+ content = ensureLineBefore(
235
+ content,
236
+ 'RUN pnpm --filter @forgeon/api prisma:generate',
237
+ 'RUN pnpm --filter @forgeon/swagger build',
238
+ );
239
+
240
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
241
+ }
242
+
243
+ function patchCompose(targetRoot) {
244
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
245
+ if (!fs.existsSync(composePath)) {
246
+ return;
247
+ }
248
+
249
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
250
+ if (!content.includes('SWAGGER_ENABLED: ${SWAGGER_ENABLED}')) {
251
+ content = content.replace(
252
+ /^(\s+DATABASE_URL:.*)$/m,
253
+ `$1
254
+ SWAGGER_ENABLED: \${SWAGGER_ENABLED}
255
+ SWAGGER_PATH: \${SWAGGER_PATH}
256
+ SWAGGER_TITLE: \${SWAGGER_TITLE}
257
+ SWAGGER_VERSION: \${SWAGGER_VERSION}`,
258
+ );
259
+ }
260
+
261
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
262
+ }
263
+
264
+ function patchReadme(targetRoot) {
265
+ const readmePath = path.join(targetRoot, 'README.md');
266
+ if (!fs.existsSync(readmePath)) {
267
+ return;
268
+ }
269
+
270
+ const marker = '## Swagger / OpenAPI Module';
271
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
272
+ if (content.includes(marker)) {
273
+ return;
274
+ }
275
+
276
+ const section = `## Swagger / OpenAPI Module
277
+
278
+ The swagger add-module provides generated OpenAPI docs for the API.
279
+
280
+ Configuration (env):
281
+ - \`SWAGGER_ENABLED=false\`
282
+ - \`SWAGGER_PATH=docs\`
283
+ - \`SWAGGER_TITLE="Forgeon API"\`
284
+ - \`SWAGGER_VERSION=1.0.0\`
285
+
286
+ When enabled:
287
+ - UI endpoint: \`http://localhost:3000/docs\` (or your configured path)
288
+ - in Docker with proxy: \`http://localhost:8080/docs\`
289
+ `;
290
+
291
+ if (content.includes('## Prisma In Docker Start')) {
292
+ content = content.replace('## Prisma In Docker Start', `${section}\n## Prisma In Docker Start`);
293
+ } else {
294
+ content = `${content.trimEnd()}\n\n${section}\n`;
295
+ }
296
+
297
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
298
+ }
299
+
300
+ export function applySwaggerModule({ packageRoot, targetRoot }) {
301
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'swagger'));
302
+ patchApiPackage(targetRoot);
303
+ patchMain(targetRoot);
304
+ patchAppModule(targetRoot);
305
+ patchApiDockerfile(targetRoot);
306
+ patchCompose(targetRoot);
307
+ patchReadme(targetRoot);
308
+
309
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
310
+ 'SWAGGER_ENABLED=false',
311
+ 'SWAGGER_PATH=docs',
312
+ 'SWAGGER_TITLE="Forgeon API"',
313
+ 'SWAGGER_VERSION=1.0.0',
314
+ ]);
315
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
316
+ 'SWAGGER_ENABLED=false',
317
+ 'SWAGGER_PATH=docs',
318
+ 'SWAGGER_TITLE="Forgeon API"',
319
+ 'SWAGGER_VERSION=1.0.0',
320
+ ]);
321
+ }
@@ -0,0 +1,6 @@
1
+ # {{MODULE_LABEL}}
2
+
3
+ - Id: `{{MODULE_ID}}`
4
+ - Category: `{{MODULE_CATEGORY}}`
5
+ - Status: {{MODULE_STATUS}}
6
+
@@ -0,0 +1,10 @@
1
+ ## Overview
2
+
3
+ Adds OpenAPI docs support to the API.
4
+
5
+ Included parts:
6
+ - `@forgeon/swagger` package
7
+ - env-driven toggle (`SWAGGER_ENABLED`)
8
+ - configurable docs path/title/version
9
+ - `setupSwagger(...)` bootstrap helper
10
+
@@ -0,0 +1,13 @@
1
+ ## Applied Scope
2
+
3
+ - Adds `packages/swagger` workspace package
4
+ - Wires swagger env schema into API config load/validation
5
+ - Registers `ForgeonSwaggerModule` in API `AppModule`
6
+ - Calls `setupSwagger(...)` from API bootstrap (`main.ts`)
7
+ - Updates API package/deps/scripts for swagger package build
8
+ - Updates API Docker build to include `@forgeon/swagger`
9
+ - Adds swagger env keys to:
10
+ - `apps/api/.env.example`
11
+ - `infra/docker/.env.example`
12
+ - `infra/docker/compose.yml` (api service env passthrough)
13
+
@@ -0,0 +1,4 @@
1
+ ## Status
2
+
3
+ Implemented and applied by `create-forgeon add swagger`.
4
+
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@forgeon/swagger",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json"
9
+ },
10
+ "dependencies": {
11
+ "@nestjs/common": "^11.0.1",
12
+ "@nestjs/config": "^4.0.2",
13
+ "@nestjs/swagger": "^11.2.0",
14
+ "zod": "^3.23.8"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.10.7",
18
+ "typescript": "^5.7.3"
19
+ }
20
+ }
21
+
@@ -0,0 +1,9 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { SwaggerConfigModule } from './swagger-config.module';
3
+
4
+ @Module({
5
+ imports: [SwaggerConfigModule],
6
+ exports: [SwaggerConfigModule],
7
+ })
8
+ export class ForgeonSwaggerModule {}
9
+
@@ -0,0 +1,7 @@
1
+ export * from './swagger-env.schema';
2
+ export * from './swagger-config.loader';
3
+ export * from './swagger-config.service';
4
+ export * from './swagger-config.module';
5
+ export * from './forgeon-swagger.module';
6
+ export * from './setup-swagger';
7
+
@@ -0,0 +1,25 @@
1
+ import { INestApplication } from '@nestjs/common';
2
+ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
3
+ import { SwaggerConfigService } from './swagger-config.service';
4
+
5
+ export function setupSwagger(app: INestApplication, config: SwaggerConfigService): void {
6
+ if (!config.enabled) {
7
+ return;
8
+ }
9
+
10
+ const document = SwaggerModule.createDocument(
11
+ app,
12
+ new DocumentBuilder()
13
+ .setTitle(config.title)
14
+ .setVersion(config.version)
15
+ .addBearerAuth()
16
+ .build(),
17
+ );
18
+
19
+ SwaggerModule.setup(config.path, app, document, {
20
+ swaggerOptions: {
21
+ persistAuthorization: true,
22
+ },
23
+ });
24
+ }
25
+
@@ -0,0 +1,22 @@
1
+ import { registerAs } from '@nestjs/config';
2
+ import { parseSwaggerEnv } from './swagger-env.schema';
3
+
4
+ export const SWAGGER_CONFIG_NAMESPACE = 'swagger';
5
+
6
+ export interface SwaggerConfigValues {
7
+ enabled: boolean;
8
+ path: string;
9
+ title: string;
10
+ version: string;
11
+ }
12
+
13
+ export const swaggerConfig = registerAs(SWAGGER_CONFIG_NAMESPACE, (): SwaggerConfigValues => {
14
+ const env = parseSwaggerEnv(process.env);
15
+ return {
16
+ enabled: env.SWAGGER_ENABLED,
17
+ path: env.SWAGGER_PATH.replace(/^\/+/, ''),
18
+ title: env.SWAGGER_TITLE,
19
+ version: env.SWAGGER_VERSION,
20
+ };
21
+ });
22
+
@@ -0,0 +1,11 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { SwaggerConfigService } from './swagger-config.service';
4
+
5
+ @Module({
6
+ imports: [ConfigModule],
7
+ providers: [SwaggerConfigService],
8
+ exports: [SwaggerConfigService],
9
+ })
10
+ export class SwaggerConfigModule {}
11
+
@@ -0,0 +1,29 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { SWAGGER_CONFIG_NAMESPACE, SwaggerConfigValues } from './swagger-config.loader';
4
+
5
+ @Injectable()
6
+ export class SwaggerConfigService {
7
+ constructor(private readonly configService: ConfigService) {}
8
+
9
+ get enabled(): boolean {
10
+ return this.configService.getOrThrow<boolean>(`${SWAGGER_CONFIG_NAMESPACE}.enabled`);
11
+ }
12
+
13
+ get path(): string {
14
+ return this.configService.getOrThrow<string>(`${SWAGGER_CONFIG_NAMESPACE}.path`);
15
+ }
16
+
17
+ get title(): string {
18
+ return this.configService.getOrThrow<SwaggerConfigValues['title']>(
19
+ `${SWAGGER_CONFIG_NAMESPACE}.title`,
20
+ );
21
+ }
22
+
23
+ get version(): string {
24
+ return this.configService.getOrThrow<SwaggerConfigValues['version']>(
25
+ `${SWAGGER_CONFIG_NAMESPACE}.version`,
26
+ );
27
+ }
28
+ }
29
+
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+
3
+ export const swaggerEnvSchema = z
4
+ .object({
5
+ SWAGGER_ENABLED: z.coerce.boolean().default(false),
6
+ SWAGGER_PATH: z.string().trim().min(1).default('docs'),
7
+ SWAGGER_TITLE: z.string().trim().min(1).default('Forgeon API'),
8
+ SWAGGER_VERSION: z.string().trim().min(1).default('1.0.0'),
9
+ })
10
+ .passthrough();
11
+
12
+ export type SwaggerEnv = z.infer<typeof swaggerEnvSchema>;
13
+
14
+ export function parseSwaggerEnv(input: Record<string, unknown>): SwaggerEnv {
15
+ return swaggerEnvSchema.parse(input);
16
+ }
17
+
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.node.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }
10
+