@soapjs/cli 1.0.0
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/.nvmrc +1 -0
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/build/cli.d.ts +3 -0
- package/build/cli.js +50 -0
- package/build/commands/add/add.command.d.ts +2 -0
- package/build/commands/add/add.command.js +709 -0
- package/build/commands/add/command-plan.d.ts +15 -0
- package/build/commands/add/command-plan.js +182 -0
- package/build/commands/add/entity-plan.d.ts +7 -0
- package/build/commands/add/entity-plan.js +106 -0
- package/build/commands/add/event-plan.d.ts +8 -0
- package/build/commands/add/event-plan.js +59 -0
- package/build/commands/add/query-plan.d.ts +10 -0
- package/build/commands/add/query-plan.js +156 -0
- package/build/commands/add/repository-plan.d.ts +11 -0
- package/build/commands/add/repository-plan.js +252 -0
- package/build/commands/add/resource-plan.d.ts +52 -0
- package/build/commands/add/resource-plan.js +2031 -0
- package/build/commands/add/route-plan.d.ts +24 -0
- package/build/commands/add/route-plan.js +256 -0
- package/build/commands/add/socket-plan.d.ts +9 -0
- package/build/commands/add/socket-plan.js +81 -0
- package/build/commands/add/use-case-plan.d.ts +7 -0
- package/build/commands/add/use-case-plan.js +86 -0
- package/build/commands/check/check.command.d.ts +2 -0
- package/build/commands/check/check.command.js +113 -0
- package/build/commands/create/create.command.d.ts +2 -0
- package/build/commands/create/create.command.js +234 -0
- package/build/commands/create/project-plan.d.ts +44 -0
- package/build/commands/create/project-plan.js +1430 -0
- package/build/commands/doctor/doctor.command.d.ts +2 -0
- package/build/commands/doctor/doctor.command.js +38 -0
- package/build/commands/generate/bruno-analysis.d.ts +19 -0
- package/build/commands/generate/bruno-analysis.js +51 -0
- package/build/commands/generate/bruno-plan.d.ts +6 -0
- package/build/commands/generate/bruno-plan.js +326 -0
- package/build/commands/generate/generate.command.d.ts +2 -0
- package/build/commands/generate/generate.command.js +130 -0
- package/build/commands/info/info.command.d.ts +2 -0
- package/build/commands/info/info.command.js +26 -0
- package/build/commands/remove/remove.command.d.ts +2 -0
- package/build/commands/remove/remove.command.js +328 -0
- package/build/commands/shared/common-options.d.ts +10 -0
- package/build/commands/shared/common-options.js +23 -0
- package/build/commands/update/update.command.d.ts +2 -0
- package/build/commands/update/update.command.js +155 -0
- package/build/config/auth-policy.d.ts +4 -0
- package/build/config/auth-policy.js +54 -0
- package/build/config/find-soap-root.d.ts +1 -0
- package/build/config/find-soap-root.js +22 -0
- package/build/config/load-soap-config.d.ts +2 -0
- package/build/config/load-soap-config.js +30 -0
- package/build/config/schemas/types.d.ts +127 -0
- package/build/config/schemas/types.js +2 -0
- package/build/config/schemas/validation.d.ts +5 -0
- package/build/config/schemas/validation.js +130 -0
- package/build/config/soap-config.service.d.ts +4 -0
- package/build/config/soap-config.service.js +24 -0
- package/build/config/write-soap-config.d.ts +8 -0
- package/build/config/write-soap-config.js +25 -0
- package/build/core/command-context.d.ts +20 -0
- package/build/core/command-context.js +30 -0
- package/build/core/errors.d.ts +6 -0
- package/build/core/errors.js +23 -0
- package/build/core/output.d.ts +12 -0
- package/build/core/output.js +30 -0
- package/build/core/result.d.ts +9 -0
- package/build/core/result.js +11 -0
- package/build/dependencies/dependency-resolver.d.ts +6 -0
- package/build/dependencies/dependency-resolver.js +68 -0
- package/build/dependencies/package-manager.d.ts +7 -0
- package/build/dependencies/package-manager.js +54 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +9 -0
- package/build/io/conflict-policy.d.ts +10 -0
- package/build/io/conflict-policy.js +32 -0
- package/build/io/file-writer.d.ts +19 -0
- package/build/io/file-writer.js +65 -0
- package/build/io/format-file.d.ts +1 -0
- package/build/io/format-file.js +13 -0
- package/build/presets/create-presets.d.ts +4 -0
- package/build/presets/create-presets.js +97 -0
- package/build/presets/index.d.ts +2 -0
- package/build/presets/index.js +18 -0
- package/build/presets/preset.types.d.ts +6 -0
- package/build/presets/preset.types.js +2 -0
- package/build/prompts/add-resource.prompt.d.ts +13 -0
- package/build/prompts/add-resource.prompt.js +80 -0
- package/build/prompts/add-route.prompt.d.ts +16 -0
- package/build/prompts/add-route.prompt.js +140 -0
- package/build/prompts/create-project.prompt.d.ts +11 -0
- package/build/prompts/create-project.prompt.js +156 -0
- package/build/prompts/generate-bruno.prompt.d.ts +7 -0
- package/build/prompts/generate-bruno.prompt.js +21 -0
- package/build/prompts/index.d.ts +8 -0
- package/build/prompts/index.js +24 -0
- package/build/prompts/inquirer-prompt-adapter.d.ts +8 -0
- package/build/prompts/inquirer-prompt-adapter.js +52 -0
- package/build/prompts/mock-prompt-adapter.d.ts +13 -0
- package/build/prompts/mock-prompt-adapter.js +60 -0
- package/build/prompts/prompt-adapter.d.ts +7 -0
- package/build/prompts/prompt-adapter.js +2 -0
- package/build/prompts/prompt.types.d.ts +26 -0
- package/build/prompts/prompt.types.js +2 -0
- package/build/registry/registry.service.d.ts +19 -0
- package/build/registry/registry.service.js +68 -0
- package/build/resolvers/add-resource.resolver.d.ts +23 -0
- package/build/resolvers/add-resource.resolver.js +73 -0
- package/build/resolvers/add-route.resolver.d.ts +34 -0
- package/build/resolvers/add-route.resolver.js +83 -0
- package/build/resolvers/create-config.resolver.d.ts +32 -0
- package/build/resolvers/create-config.resolver.js +57 -0
- package/build/resolvers/generate-bruno.resolver.d.ts +17 -0
- package/build/resolvers/generate-bruno.resolver.js +23 -0
- package/build/resolvers/index.d.ts +5 -0
- package/build/resolvers/index.js +21 -0
- package/build/resolvers/resolver.types.d.ts +8 -0
- package/build/resolvers/resolver.types.js +2 -0
- package/build/summary/create-summary.d.ts +2 -0
- package/build/summary/create-summary.js +24 -0
- package/build/summary/index.d.ts +1 -0
- package/build/summary/index.js +17 -0
- package/build/templates/naming.d.ts +11 -0
- package/build/templates/naming.js +30 -0
- package/build/templates/template-context.d.ts +6 -0
- package/build/templates/template-context.js +2 -0
- package/build/templates/template-engine.d.ts +1 -0
- package/build/templates/template-engine.js +10 -0
- package/build/templates/template-resolver.d.ts +2 -0
- package/build/templates/template-resolver.js +17 -0
- package/build/terminal/terminal-capabilities.d.ts +6 -0
- package/build/terminal/terminal-capabilities.js +14 -0
- package/docs/adr/0001-soap-cli-project-aware-generator.md +108 -0
- package/docs/cli/add-resource.md +127 -0
- package/docs/cli/add-route.md +79 -0
- package/docs/cli/bruno.md +58 -0
- package/docs/cli/create.md +73 -0
- package/docs/cli/index.md +92 -0
- package/docs/cli/interactive-mode.md +61 -0
- package/docs/cli/remove.md +45 -0
- package/docs/guides/auth.md +90 -0
- package/docs/guides/cqrs-events-realtime.md +100 -0
- package/docs/guides/index.md +24 -0
- package/docs/guides/quality-and-safety.md +88 -0
- package/docs/guides/regular-api.md +119 -0
- package/docs/guides/storage.md +101 -0
- package/docs/plans/interactive-mode-plan.md +601 -0
- package/package.json +44 -0
|
@@ -0,0 +1,1430 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createControllersTs = exports.createResourcesTs = exports.createProjectFiles = exports.targetRoot = exports.zonesOptions = exports.contractsOptions = exports.docsOptions = exports.telemetryOptions = exports.realtimeOptions = exports.messagingOptions = exports.authOptions = exports.databaseOptions = exports.parseCsvOption = exports.createSoapConfigBundle = exports.createDefaultCapabilities = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const naming_1 = require("../../templates/naming");
|
|
9
|
+
function createDefaultCapabilities() {
|
|
10
|
+
return {
|
|
11
|
+
databases: [],
|
|
12
|
+
auth: [],
|
|
13
|
+
messaging: ["in-memory"],
|
|
14
|
+
realtime: [],
|
|
15
|
+
telemetry: ["logs"],
|
|
16
|
+
apiClient: [],
|
|
17
|
+
docs: [],
|
|
18
|
+
contracts: [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
exports.createDefaultCapabilities = createDefaultCapabilities;
|
|
22
|
+
function createSoapConfigBundle(plan) {
|
|
23
|
+
const defaultAuth = plan.capabilities.auth[0] ?? "none";
|
|
24
|
+
const brunoEnabled = plan.capabilities.apiClient.includes("bruno");
|
|
25
|
+
return {
|
|
26
|
+
project: {
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
name: plan.name,
|
|
29
|
+
framework: plan.framework,
|
|
30
|
+
architecture: plan.architecture,
|
|
31
|
+
language: "typescript",
|
|
32
|
+
packageManager: plan.packageManager,
|
|
33
|
+
capabilities: plan.capabilities,
|
|
34
|
+
zones: plan.zones,
|
|
35
|
+
},
|
|
36
|
+
structure: {
|
|
37
|
+
featuresRoot: "src/features",
|
|
38
|
+
commonRoot: "src/common",
|
|
39
|
+
configRoot: "src/config",
|
|
40
|
+
paths: {
|
|
41
|
+
domain: "domain",
|
|
42
|
+
application: "application",
|
|
43
|
+
ports: "application/ports",
|
|
44
|
+
useCases: "application/use-cases",
|
|
45
|
+
commands: "application/commands",
|
|
46
|
+
queries: "application/queries",
|
|
47
|
+
data: "data",
|
|
48
|
+
api: "api",
|
|
49
|
+
contracts: "contracts",
|
|
50
|
+
sockets: "api/sockets",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
api: {
|
|
54
|
+
baseUrl: "http://localhost:3000",
|
|
55
|
+
health: {
|
|
56
|
+
method: "GET",
|
|
57
|
+
path: "/health",
|
|
58
|
+
},
|
|
59
|
+
auth: {
|
|
60
|
+
default: defaultAuth,
|
|
61
|
+
loginRoute: defaultAuth === "none"
|
|
62
|
+
? undefined
|
|
63
|
+
: {
|
|
64
|
+
method: "POST",
|
|
65
|
+
path: "/auth/login",
|
|
66
|
+
tokenVariable: "accessToken",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
bruno: {
|
|
70
|
+
enabled: brunoEnabled,
|
|
71
|
+
collectionPath: "bruno",
|
|
72
|
+
environment: "Local",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
registry: {
|
|
76
|
+
resources: [],
|
|
77
|
+
routes: [],
|
|
78
|
+
generatedFiles: [],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
exports.createSoapConfigBundle = createSoapConfigBundle;
|
|
83
|
+
function parseCsvOption(value, allowed) {
|
|
84
|
+
if (!value) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const rawValues = Array.isArray(value) ? value : [value];
|
|
88
|
+
const result = rawValues.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
|
89
|
+
const normalized = result.filter((item) => item !== "none");
|
|
90
|
+
const invalid = normalized.find((item) => !allowed.includes(item));
|
|
91
|
+
if (invalid) {
|
|
92
|
+
throw new Error(`Unsupported option value "${invalid}". Allowed values: none, ${allowed.join(", ")}.`);
|
|
93
|
+
}
|
|
94
|
+
return Array.from(new Set(normalized));
|
|
95
|
+
}
|
|
96
|
+
exports.parseCsvOption = parseCsvOption;
|
|
97
|
+
exports.databaseOptions = ["mongo", "postgres", "mysql", "sqlite", "redis"];
|
|
98
|
+
const sqlDatabaseOptions = ["postgres", "mysql", "sqlite"];
|
|
99
|
+
exports.authOptions = ["jwt", "api-key", "local"];
|
|
100
|
+
exports.messagingOptions = ["in-memory", "kafka"];
|
|
101
|
+
exports.realtimeOptions = ["ws"];
|
|
102
|
+
exports.telemetryOptions = ["logs", "otel-noop"];
|
|
103
|
+
exports.docsOptions = ["openapi"];
|
|
104
|
+
exports.contractsOptions = ["zod"];
|
|
105
|
+
exports.zonesOptions = ["public", "private", "admin"];
|
|
106
|
+
function enabledSqlDatabases(capabilities) {
|
|
107
|
+
return sqlDatabaseOptions.filter((database) => capabilities.databases.includes(database));
|
|
108
|
+
}
|
|
109
|
+
function targetRoot(cwd, name) {
|
|
110
|
+
return path_1.default.resolve(cwd, name);
|
|
111
|
+
}
|
|
112
|
+
exports.targetRoot = targetRoot;
|
|
113
|
+
function createProjectFiles(plan) {
|
|
114
|
+
const packageJson = {
|
|
115
|
+
name: plan.name,
|
|
116
|
+
version: "0.1.0",
|
|
117
|
+
private: true,
|
|
118
|
+
engines: {
|
|
119
|
+
node: ">=24.17.0",
|
|
120
|
+
},
|
|
121
|
+
scripts: {
|
|
122
|
+
dev: "tsx src/index.ts",
|
|
123
|
+
"dev:watch": "tsx watch src/index.ts",
|
|
124
|
+
build: "tsc -p tsconfig.json",
|
|
125
|
+
start: "node build/index.js",
|
|
126
|
+
test: "npm run build && find build -name '*.spec.js' -o -name '*.test.js' | xargs node --test",
|
|
127
|
+
lint: "tsc -p tsconfig.json --noEmit",
|
|
128
|
+
bruno: plan.capabilities.apiClient.includes("bruno") ? "cd bruno && bru run --env Local" : "echo \"Bruno is not enabled\"",
|
|
129
|
+
"test:api": plan.capabilities.apiClient.includes("bruno") ? "npm run bruno" : "echo \"Bruno is not enabled\"",
|
|
130
|
+
},
|
|
131
|
+
dependencies: plan.dependencies.dependencies,
|
|
132
|
+
devDependencies: plan.dependencies.devDependencies,
|
|
133
|
+
};
|
|
134
|
+
const dockerServices = createDockerCompose(plan);
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
path: "package.json",
|
|
138
|
+
type: "project",
|
|
139
|
+
content: JSON.stringify(packageJson, null, 2),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
path: "tsconfig.json",
|
|
143
|
+
type: "project",
|
|
144
|
+
content: JSON.stringify({
|
|
145
|
+
compilerOptions: {
|
|
146
|
+
target: "ES2022",
|
|
147
|
+
module: "commonjs",
|
|
148
|
+
moduleResolution: "node",
|
|
149
|
+
strict: true,
|
|
150
|
+
esModuleInterop: true,
|
|
151
|
+
skipLibCheck: true,
|
|
152
|
+
forceConsistentCasingInFileNames: true,
|
|
153
|
+
experimentalDecorators: true,
|
|
154
|
+
emitDecoratorMetadata: true,
|
|
155
|
+
outDir: "build",
|
|
156
|
+
rootDir: "src",
|
|
157
|
+
},
|
|
158
|
+
include: ["src/**/*"],
|
|
159
|
+
exclude: ["node_modules", "build"],
|
|
160
|
+
}, null, 2),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
path: ".gitignore",
|
|
164
|
+
type: "project",
|
|
165
|
+
content: ["node_modules", "build", ".env", "*.log", ".DS_Store"].join("\n"),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
path: ".nvmrc",
|
|
169
|
+
type: "project",
|
|
170
|
+
content: "24.17.0\n",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
path: ".dockerignore",
|
|
174
|
+
type: "docker",
|
|
175
|
+
content: ["node_modules", "build", ".git", ".env", "bruno"].join("\n"),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
path: ".env.example",
|
|
179
|
+
type: "config",
|
|
180
|
+
content: createEnvExample(plan),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
path: "README.md",
|
|
184
|
+
type: "project",
|
|
185
|
+
content: createReadme(plan),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
path: "Dockerfile",
|
|
189
|
+
type: "docker",
|
|
190
|
+
content: createDockerfile(),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
path: "docker-compose.yml",
|
|
194
|
+
type: "docker",
|
|
195
|
+
content: dockerServices,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
path: "Makefile",
|
|
199
|
+
type: "project",
|
|
200
|
+
content: createMakefile(),
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
path: "src/index.ts",
|
|
204
|
+
type: "project",
|
|
205
|
+
content: createIndexTs(plan),
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
path: "src/config/config.ts",
|
|
209
|
+
type: "config",
|
|
210
|
+
content: createConfigTs(plan),
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
path: "src/config/dependencies.ts",
|
|
214
|
+
type: "config",
|
|
215
|
+
content: createDependenciesTs(plan),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
path: "src/config/resources.ts",
|
|
219
|
+
type: "config",
|
|
220
|
+
content: createResourcesTs([]),
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
path: "src/config/controllers.ts",
|
|
224
|
+
type: "config",
|
|
225
|
+
content: createControllersTs(createInitialControllers(plan)),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
path: "src/features/index.ts",
|
|
229
|
+
type: "config",
|
|
230
|
+
content: createFeaturesIndexTs(plan),
|
|
231
|
+
},
|
|
232
|
+
...createCqrsFiles(plan),
|
|
233
|
+
...createMongoFiles(plan),
|
|
234
|
+
...createSqlFiles(plan),
|
|
235
|
+
...createEventFiles(plan),
|
|
236
|
+
...createSocketFiles(plan),
|
|
237
|
+
...createAuthFiles(plan),
|
|
238
|
+
{
|
|
239
|
+
path: "src/features/health/.gitkeep",
|
|
240
|
+
type: "project",
|
|
241
|
+
content: "",
|
|
242
|
+
},
|
|
243
|
+
...createBrunoFiles(plan),
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
exports.createProjectFiles = createProjectFiles;
|
|
247
|
+
function createIndexTs(plan) {
|
|
248
|
+
const docsImports = plan.capabilities.docs.includes("openapi")
|
|
249
|
+
? "import { DocumentationPlugin } from '@soapjs/soap-openapi';\n"
|
|
250
|
+
: "";
|
|
251
|
+
const socketImport = plan.capabilities.realtime.includes("ws")
|
|
252
|
+
? "import { createSocketRuntime } from './common/sockets/socket.setup';\n"
|
|
253
|
+
: "";
|
|
254
|
+
const cqrsImport = plan.architecture === "cqrs" ? "import './config/cqrs';\n" : "";
|
|
255
|
+
const docsPlugin = plan.capabilities.docs.includes("openapi")
|
|
256
|
+
? `\n plugins: [\n {\n plugin: new DocumentationPlugin(),\n options: {\n info: {\n title: '${plan.name} API',\n version: '0.1.0',\n },\n servers: [{ url: \`http://localhost:\${config.port}\`, description: 'Local' }],\n interactivePath: '/docs',\n openApiPath: '/openapi.json',\n },\n },\n ],`
|
|
257
|
+
: "";
|
|
258
|
+
const cqrsOption = plan.architecture === "cqrs" ? "\n cqrs: true," : "";
|
|
259
|
+
return `import 'reflect-metadata';
|
|
260
|
+
import { bootstrap } from '@soapjs/soap-express';
|
|
261
|
+
${docsImports}${socketImport}${cqrsImport}import './features';
|
|
262
|
+
import { config } from './config/config';
|
|
263
|
+
import { controllers } from './config/controllers';
|
|
264
|
+
import { buildContainer } from './config/dependencies';
|
|
265
|
+
|
|
266
|
+
async function main(): Promise<void> {
|
|
267
|
+
const { container, drainables, logger, authStrategies } = await buildContainer(config);
|
|
268
|
+
|
|
269
|
+
const app = await bootstrap({
|
|
270
|
+
port: config.port,
|
|
271
|
+
container,
|
|
272
|
+
logger,
|
|
273
|
+
drainables,
|
|
274
|
+
controllers,
|
|
275
|
+
middleware: {
|
|
276
|
+
cors: true,
|
|
277
|
+
helmet: true,
|
|
278
|
+
logging: true,
|
|
279
|
+
compression: true,
|
|
280
|
+
},
|
|
281
|
+
auth: authStrategies,
|
|
282
|
+
healthCheck: true,${cqrsOption}${docsPlugin}
|
|
283
|
+
});
|
|
284
|
+
${plan.capabilities.realtime.includes("ws") ? `\n const socketRuntime = createSocketRuntime(config, app.getServer());\n app.registerDrainable(socketRuntime);\n` : ""}
|
|
285
|
+
|
|
286
|
+
logger.info('${plan.name} API ready', { port: config.port });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
main().catch((error) => {
|
|
290
|
+
console.error(error);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
});
|
|
293
|
+
`;
|
|
294
|
+
}
|
|
295
|
+
function createConfigTs(plan) {
|
|
296
|
+
const mongo = plan.capabilities.databases.includes("mongo")
|
|
297
|
+
? "\n mongoUri: readEnv('MONGO_URI', 'mongodb://localhost:27017/app'),"
|
|
298
|
+
: "";
|
|
299
|
+
const postgres = plan.capabilities.databases.includes("postgres")
|
|
300
|
+
? "\n postgres: {\n host: readEnv('POSTGRES_HOST', 'localhost'),\n port: Number(readEnv('POSTGRES_PORT', '5432')),\n database: readEnv('POSTGRES_DB', 'app'),\n user: readEnv('POSTGRES_USER', 'app'),\n password: readEnv('POSTGRES_PASSWORD', 'app'),\n },"
|
|
301
|
+
: "";
|
|
302
|
+
const mysql = plan.capabilities.databases.includes("mysql")
|
|
303
|
+
? "\n mysql: {\n host: readEnv('MYSQL_HOST', 'localhost'),\n port: Number(readEnv('MYSQL_PORT', '3306')),\n database: readEnv('MYSQL_DATABASE', 'app'),\n user: readEnv('MYSQL_USER', 'app'),\n password: readEnv('MYSQL_PASSWORD', 'app'),\n },"
|
|
304
|
+
: "";
|
|
305
|
+
const sqlite = plan.capabilities.databases.includes("sqlite")
|
|
306
|
+
? "\n sqlite: {\n filename: readEnv('SQLITE_FILENAME', './data/app.sqlite'),\n },"
|
|
307
|
+
: "";
|
|
308
|
+
const redis = plan.capabilities.databases.includes("redis")
|
|
309
|
+
? "\n redisUrl: readEnv('REDIS_URL', 'redis://localhost:6379'),"
|
|
310
|
+
: "";
|
|
311
|
+
const kafka = plan.capabilities.messaging.includes("kafka")
|
|
312
|
+
? "\n kafkaBrokers: readEnv('KAFKA_BROKERS', 'localhost:9092').split(','),\n eventBus: readEnv('EVENT_BUS', 'in-memory'),"
|
|
313
|
+
: "\n eventBus: 'in-memory',";
|
|
314
|
+
const sockets = plan.capabilities.realtime.includes("ws")
|
|
315
|
+
? "\n wsPath: readEnv('WS_PATH', '/ws'),\n wsHeartbeatMs: Number(readEnv('WS_HEARTBEAT_MS', '30000')),"
|
|
316
|
+
: "";
|
|
317
|
+
const jwt = plan.capabilities.auth.includes("jwt") || plan.capabilities.auth.includes("local")
|
|
318
|
+
? "\n jwtSecret: readEnv('JWT_SECRET', 'dev-secret'),"
|
|
319
|
+
: "";
|
|
320
|
+
const apiKey = plan.capabilities.auth.includes("api-key")
|
|
321
|
+
? "\n apiKeyHeader: readEnv('API_KEY_HEADER', 'x-api-key'),\n devApiKey: readEnv('DEV_API_KEY', 'dev-api-key'),"
|
|
322
|
+
: "";
|
|
323
|
+
return `import 'dotenv/config';
|
|
324
|
+
|
|
325
|
+
function readEnv(name: string, fallback?: string): string {
|
|
326
|
+
const value = process.env[name] ?? fallback;
|
|
327
|
+
|
|
328
|
+
if (value === undefined || value === '') {
|
|
329
|
+
throw new Error(\`Missing required environment variable: \${name}\`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const config = {
|
|
336
|
+
nodeEnv: readEnv('NODE_ENV', 'development'),
|
|
337
|
+
port: Number(readEnv('PORT', '3000')),
|
|
338
|
+
logLevel: readEnv('LOG_LEVEL', 'debug'),${mongo}${postgres}${mysql}${sqlite}${redis}${kafka}${sockets}${jwt}${apiKey}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export type AppConfig = typeof config;
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
function createDependenciesTs(plan) {
|
|
345
|
+
const authImport = plan.capabilities.auth.length > 0
|
|
346
|
+
? "import { createAuthStrategies } from '../features/auth/auth.setup';\n"
|
|
347
|
+
: "";
|
|
348
|
+
const mongoImport = plan.capabilities.databases.includes("mongo")
|
|
349
|
+
? "import { SoapMongo } from '@soapjs/soap-node-mongo';\nimport { createMongoClient } from '../common/data/mongo/mongo.client';\n"
|
|
350
|
+
: "";
|
|
351
|
+
const sqlDatabases = enabledSqlDatabases(plan.capabilities);
|
|
352
|
+
const sqlImport = sqlDatabases.length > 0
|
|
353
|
+
? `import { SoapSQL } from '@soapjs/soap-node-sql';
|
|
354
|
+
${sqlDatabases.map((database) => `import { create${(0, naming_1.createNameVariants)(database).pascalName}Client } from '../common/data/${database}/${database}.client';`).join("\n")}
|
|
355
|
+
`
|
|
356
|
+
: "";
|
|
357
|
+
const eventImport = plan.capabilities.messaging.length > 0
|
|
358
|
+
? "import { DomainEventBus } from '@soapjs/soap/cqrs';\nimport { createEventBus } from '../common/events/event-bus.setup';\n"
|
|
359
|
+
: "";
|
|
360
|
+
const authStrategies = plan.capabilities.auth.length > 0 ? "createAuthStrategies(_config)" : "[]";
|
|
361
|
+
const resourceContextType = [
|
|
362
|
+
plan.capabilities.databases.includes("mongo") ? " mongo?: SoapMongo;" : undefined,
|
|
363
|
+
sqlDatabases.length > 0 ? " sql?: Partial<Record<'postgres' | 'mysql' | 'sqlite', SoapSQL>>;" : undefined,
|
|
364
|
+
].filter(Boolean).join("\n");
|
|
365
|
+
const mongoSetup = plan.capabilities.databases.includes("mongo")
|
|
366
|
+
? `\n const mongo = await createMongoClient(_config);\n drainables.push(mongo);\n resources.mongo = mongo;\n`
|
|
367
|
+
: "";
|
|
368
|
+
const sqlSetup = sqlDatabases.length > 0
|
|
369
|
+
? `\n resources.sql = {};
|
|
370
|
+
${sqlDatabases.map((database) => {
|
|
371
|
+
const names = (0, naming_1.createNameVariants)(database);
|
|
372
|
+
return ` const ${names.camelName} = await create${names.pascalName}Client(_config);
|
|
373
|
+
drainables.push(${names.camelName});
|
|
374
|
+
resources.sql.${database} = ${names.camelName};`;
|
|
375
|
+
}).join("\n")}\n`
|
|
376
|
+
: "";
|
|
377
|
+
const eventSetup = plan.capabilities.messaging.length > 0
|
|
378
|
+
? `\n const eventBusRuntime = await createEventBus(_config, logger);\n container.bindValue(DomainEventBus.Token, eventBusRuntime.bus);\n if (eventBusRuntime.drainable) {\n drainables.push(eventBusRuntime.drainable);\n }\n`
|
|
379
|
+
: "";
|
|
380
|
+
return `import { ConsoleLogger, DIContainer } from '@soapjs/soap-express';
|
|
381
|
+
import { Drainable } from '@soapjs/soap/events';
|
|
382
|
+
import { AuthStrategy } from '@soapjs/soap/http';
|
|
383
|
+
import { AppConfig } from './config';
|
|
384
|
+
import { registerResources } from './resources';
|
|
385
|
+
${mongoImport}${sqlImport}${eventImport}${authImport}
|
|
386
|
+
|
|
387
|
+
export interface ResourceContext {
|
|
388
|
+
${resourceContextType}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function buildContainer(_config: AppConfig): Promise<{
|
|
392
|
+
container: DIContainer;
|
|
393
|
+
drainables: Drainable[];
|
|
394
|
+
logger: ConsoleLogger;
|
|
395
|
+
authStrategies: AuthStrategy[];
|
|
396
|
+
}> {
|
|
397
|
+
const container = new DIContainer();
|
|
398
|
+
const logger = new ConsoleLogger();
|
|
399
|
+
const drainables: Drainable[] = [];
|
|
400
|
+
const resources: ResourceContext = {};
|
|
401
|
+
const authStrategies: AuthStrategy[] = ${authStrategies};
|
|
402
|
+
${mongoSetup}${sqlSetup}${eventSetup}
|
|
403
|
+
|
|
404
|
+
await registerResources(container, resources);
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
container,
|
|
408
|
+
drainables,
|
|
409
|
+
logger,
|
|
410
|
+
authStrategies,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
`;
|
|
414
|
+
}
|
|
415
|
+
function createResourcesTs(resources) {
|
|
416
|
+
if (resources.length === 0) {
|
|
417
|
+
return `import { DIContainer } from '@soapjs/soap-express';
|
|
418
|
+
import { ResourceContext } from './dependencies';
|
|
419
|
+
|
|
420
|
+
export async function registerResources(_container: DIContainer, _resources: ResourceContext): Promise<void> {}
|
|
421
|
+
`;
|
|
422
|
+
}
|
|
423
|
+
const imports = resources
|
|
424
|
+
.map((resource) => `import { ${resource.functionName} } from '${resource.importPath}';`)
|
|
425
|
+
.join("\n");
|
|
426
|
+
return `import { DIContainer } from '@soapjs/soap-express';
|
|
427
|
+
import { ResourceContext } from './dependencies';
|
|
428
|
+
${imports}
|
|
429
|
+
|
|
430
|
+
export async function registerResources(container: DIContainer, resources: ResourceContext): Promise<void> {
|
|
431
|
+
${resources.map((resource) => ` await ${resource.functionName}(container, resources);`).join("\n")}
|
|
432
|
+
}
|
|
433
|
+
`;
|
|
434
|
+
}
|
|
435
|
+
exports.createResourcesTs = createResourcesTs;
|
|
436
|
+
function createMongoFiles(plan) {
|
|
437
|
+
if (!plan.capabilities.databases.includes("mongo")) {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
return [
|
|
441
|
+
{
|
|
442
|
+
path: "src/config/mongo.config.ts",
|
|
443
|
+
type: "config",
|
|
444
|
+
content: `import { MongoConfig } from '@soapjs/soap-node-mongo';
|
|
445
|
+
import { AppConfig } from './config';
|
|
446
|
+
|
|
447
|
+
export function createMongoConfig(config: Pick<AppConfig, 'mongoUri'>): MongoConfig {
|
|
448
|
+
const uri = new URL(config.mongoUri);
|
|
449
|
+
const database = uri.pathname.replace(/^\\//, '') || 'app';
|
|
450
|
+
const hosts = uri.hostname.split(',').filter(Boolean);
|
|
451
|
+
const ports = uri.port ? [Number(uri.port)] : undefined;
|
|
452
|
+
const user = uri.username ? decodeURIComponent(uri.username) : undefined;
|
|
453
|
+
const password = uri.password ? decodeURIComponent(uri.password) : undefined;
|
|
454
|
+
const authSource = uri.searchParams.get('authSource') ?? undefined;
|
|
455
|
+
const replicaSet = uri.searchParams.get('replicaSet') ?? undefined;
|
|
456
|
+
const srv = uri.protocol === 'mongodb+srv:';
|
|
457
|
+
|
|
458
|
+
return new MongoConfig(database, hosts, ports, user, password, undefined, authSource, undefined, replicaSet, srv);
|
|
459
|
+
}
|
|
460
|
+
`,
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
path: "src/common/data/mongo/mongo.client.ts",
|
|
464
|
+
type: "config",
|
|
465
|
+
content: `import { SoapMongo } from '@soapjs/soap-node-mongo';
|
|
466
|
+
import { AppConfig } from '../../../config/config';
|
|
467
|
+
import { createMongoConfig } from '../../../config/mongo.config';
|
|
468
|
+
|
|
469
|
+
export async function createMongoClient(config: AppConfig): Promise<SoapMongo> {
|
|
470
|
+
return SoapMongo.create(createMongoConfig(config));
|
|
471
|
+
}
|
|
472
|
+
`,
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
path: "src/common/data/mongo/mongo.source-factory.ts",
|
|
476
|
+
type: "config",
|
|
477
|
+
content: `import { MongoSource, SoapMongo } from '@soapjs/soap-node-mongo';
|
|
478
|
+
import { Document } from 'mongodb';
|
|
479
|
+
|
|
480
|
+
export function createMongoSource<T extends Document = Document>(mongo: SoapMongo, collectionName: string): MongoSource<T> {
|
|
481
|
+
return new MongoSource<T>(mongo, collectionName);
|
|
482
|
+
}
|
|
483
|
+
`,
|
|
484
|
+
},
|
|
485
|
+
];
|
|
486
|
+
}
|
|
487
|
+
function createSqlFiles(plan) {
|
|
488
|
+
const databases = enabledSqlDatabases(plan.capabilities);
|
|
489
|
+
if (databases.length === 0) {
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
return [
|
|
493
|
+
...databases.flatMap((database) => createSqlDatabaseFiles(database)),
|
|
494
|
+
{
|
|
495
|
+
path: "src/common/data/sql/sql.source-factory.ts",
|
|
496
|
+
type: "config",
|
|
497
|
+
content: `import { SoapSQL, SqlDataSource } from '@soapjs/soap-node-sql';
|
|
498
|
+
|
|
499
|
+
export function createSqlSource<T = Record<string, unknown>>(sql: SoapSQL, tableName: string): SqlDataSource<T> {
|
|
500
|
+
return new SqlDataSource<T>(sql, tableName);
|
|
501
|
+
}
|
|
502
|
+
`,
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
path: "src/common/data/sql/migrations/.gitkeep",
|
|
506
|
+
type: "config",
|
|
507
|
+
content: "",
|
|
508
|
+
},
|
|
509
|
+
];
|
|
510
|
+
}
|
|
511
|
+
function createSqlDatabaseFiles(database) {
|
|
512
|
+
const names = (0, naming_1.createNameVariants)(database);
|
|
513
|
+
const configProperty = names.camelName;
|
|
514
|
+
return [
|
|
515
|
+
{
|
|
516
|
+
path: `src/config/${database}.config.ts`,
|
|
517
|
+
type: "config",
|
|
518
|
+
content: `import { SqlDatabaseConfig } from '@soapjs/soap-node-sql';
|
|
519
|
+
import { AppConfig } from './config';
|
|
520
|
+
|
|
521
|
+
export function create${names.pascalName}Config(config: Pick<AppConfig, '${configProperty}'>): SqlDatabaseConfig {
|
|
522
|
+
return new SqlDatabaseConfig({
|
|
523
|
+
${createSqlConfigProperties(database, configProperty)}
|
|
524
|
+
connectionLimit: 10,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
`,
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
path: `src/common/data/${database}/${database}.client.ts`,
|
|
531
|
+
type: "config",
|
|
532
|
+
content: `import { SoapSQL } from '@soapjs/soap-node-sql';
|
|
533
|
+
import { AppConfig } from '../../../config/config';
|
|
534
|
+
import { create${names.pascalName}Config } from '../../../config/${database}.config';
|
|
535
|
+
|
|
536
|
+
export async function create${names.pascalName}Client(config: AppConfig): Promise<SoapSQL> {
|
|
537
|
+
return SoapSQL.create(create${names.pascalName}Config(config));
|
|
538
|
+
}
|
|
539
|
+
`,
|
|
540
|
+
},
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
function createSqlConfigProperties(database, configProperty) {
|
|
544
|
+
if (database === "sqlite") {
|
|
545
|
+
return ` type: 'sqlite',
|
|
546
|
+
filename: config.${configProperty}.filename,`;
|
|
547
|
+
}
|
|
548
|
+
const sqlType = database === "postgres" ? "postgresql" : "mysql";
|
|
549
|
+
return ` type: '${sqlType}',
|
|
550
|
+
host: config.${configProperty}.host,
|
|
551
|
+
port: config.${configProperty}.port,
|
|
552
|
+
database: config.${configProperty}.database,
|
|
553
|
+
username: config.${configProperty}.user,
|
|
554
|
+
password: config.${configProperty}.password,`;
|
|
555
|
+
}
|
|
556
|
+
function createCqrsFiles(plan) {
|
|
557
|
+
if (plan.architecture !== "cqrs") {
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
return [
|
|
561
|
+
{
|
|
562
|
+
path: "src/config/cqrs.ts",
|
|
563
|
+
type: "config",
|
|
564
|
+
content: `// Generated CQRS handler imports. Updated by soap add command/query.
|
|
565
|
+
export {};
|
|
566
|
+
`,
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
path: "src/types/soap-express-cqrs.d.ts",
|
|
570
|
+
type: "config",
|
|
571
|
+
content: `declare module '@soapjs/soap-express/cqrs' {
|
|
572
|
+
export * from '@soapjs/soap-express/build/cqrs';
|
|
573
|
+
}
|
|
574
|
+
`,
|
|
575
|
+
},
|
|
576
|
+
];
|
|
577
|
+
}
|
|
578
|
+
function createEventFiles(plan) {
|
|
579
|
+
if (plan.capabilities.messaging.length === 0) {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
const files = [
|
|
583
|
+
{
|
|
584
|
+
path: "src/common/events/domain-event.ts",
|
|
585
|
+
type: "config",
|
|
586
|
+
content: `export { BaseDomainEvent, DomainEvent } from '@soapjs/soap/domain';
|
|
587
|
+
export { DomainEventBus, DomainEventConsumer, InMemoryDomainEventBus } from '@soapjs/soap/cqrs';
|
|
588
|
+
`,
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
path: "src/common/events/event-bus.setup.ts",
|
|
592
|
+
type: "config",
|
|
593
|
+
content: plan.capabilities.messaging.includes("kafka")
|
|
594
|
+
? `import { DomainEventBus, InMemoryDomainEventBus } from '@soapjs/soap/cqrs';
|
|
595
|
+
import { Drainable } from '@soapjs/soap/events';
|
|
596
|
+
import { Logger } from '@soapjs/soap/common';
|
|
597
|
+
import { AppConfig } from '../../config/config';
|
|
598
|
+
import { createKafkaDomainEventBus } from './kafka/kafka-event-bus';
|
|
599
|
+
|
|
600
|
+
export interface EventBusRuntime {
|
|
601
|
+
bus: DomainEventBus;
|
|
602
|
+
drainable?: Drainable;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function createEventBus(config: AppConfig, logger: Logger): Promise<EventBusRuntime> {
|
|
606
|
+
if (config.eventBus === 'kafka') {
|
|
607
|
+
const bus = createKafkaDomainEventBus(config, logger);
|
|
608
|
+
await bus.start();
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
bus,
|
|
612
|
+
drainable: {
|
|
613
|
+
close: () => bus.stop(),
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return { bus: new InMemoryDomainEventBus() };
|
|
619
|
+
}
|
|
620
|
+
`
|
|
621
|
+
: `import { DomainEventBus, InMemoryDomainEventBus } from '@soapjs/soap/cqrs';
|
|
622
|
+
import { Drainable } from '@soapjs/soap/events';
|
|
623
|
+
import { Logger } from '@soapjs/soap/common';
|
|
624
|
+
import { AppConfig } from '../../config/config';
|
|
625
|
+
|
|
626
|
+
export interface EventBusRuntime {
|
|
627
|
+
bus: DomainEventBus;
|
|
628
|
+
drainable?: Drainable;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function createEventBus(_config: AppConfig, _logger: Logger): Promise<EventBusRuntime> {
|
|
632
|
+
return { bus: new InMemoryDomainEventBus() };
|
|
633
|
+
}
|
|
634
|
+
`,
|
|
635
|
+
},
|
|
636
|
+
];
|
|
637
|
+
if (plan.capabilities.messaging.includes("kafka")) {
|
|
638
|
+
files.push({
|
|
639
|
+
path: "src/config/kafka.config.ts",
|
|
640
|
+
type: "config",
|
|
641
|
+
content: `import { AppConfig } from './config';
|
|
642
|
+
|
|
643
|
+
export function createKafkaConfig(config: Pick<AppConfig, 'kafkaBrokers'>) {
|
|
644
|
+
return {
|
|
645
|
+
brokers: config.kafkaBrokers,
|
|
646
|
+
clientId: 'soapjs-service',
|
|
647
|
+
topicName: 'domain-events',
|
|
648
|
+
groupId: 'soapjs-service',
|
|
649
|
+
ensureTopic: true,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
`,
|
|
653
|
+
}, {
|
|
654
|
+
path: "src/common/events/kafka/kafka.client.ts",
|
|
655
|
+
type: "config",
|
|
656
|
+
content: `import { KafkaEventBus } from '@soapjs/soap-node-kafka';
|
|
657
|
+
import { AppConfig } from '../../../config/config';
|
|
658
|
+
import { createKafkaConfig } from '../../../config/kafka.config';
|
|
659
|
+
|
|
660
|
+
export function createKafkaClient(config: AppConfig): KafkaEventBus<Record<string, unknown>, Record<string, unknown>> {
|
|
661
|
+
return new KafkaEventBus<Record<string, unknown>, Record<string, unknown>>(createKafkaConfig(config));
|
|
662
|
+
}
|
|
663
|
+
`,
|
|
664
|
+
}, {
|
|
665
|
+
path: "src/common/events/kafka/kafka-event-bus.ts",
|
|
666
|
+
type: "config",
|
|
667
|
+
content: `import { KafkaDomainEventBus } from '@soapjs/soap-node-kafka';
|
|
668
|
+
import { Logger } from '@soapjs/soap/common';
|
|
669
|
+
import { AppConfig } from '../../../config/config';
|
|
670
|
+
import { createKafkaClient } from './kafka.client';
|
|
671
|
+
|
|
672
|
+
export function createKafkaDomainEventBus(config: AppConfig, logger: Logger): KafkaDomainEventBus {
|
|
673
|
+
return new KafkaDomainEventBus(createKafkaClient(config), {
|
|
674
|
+
topic: 'domain-events',
|
|
675
|
+
groupId: 'soapjs-service',
|
|
676
|
+
logger,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
`,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
return files;
|
|
683
|
+
}
|
|
684
|
+
function createSocketFiles(plan) {
|
|
685
|
+
if (!plan.capabilities.realtime.includes("ws")) {
|
|
686
|
+
return [];
|
|
687
|
+
}
|
|
688
|
+
return [
|
|
689
|
+
{
|
|
690
|
+
path: "src/config/sockets.ts",
|
|
691
|
+
type: "config",
|
|
692
|
+
content: `import { AppSocketHandler } from '../common/sockets/socket.setup';
|
|
693
|
+
|
|
694
|
+
export const socketHandlers: AppSocketHandler[] = [];
|
|
695
|
+
`,
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
path: "src/common/sockets/socket.setup.ts",
|
|
699
|
+
type: "config",
|
|
700
|
+
content: `import { Server } from 'http';
|
|
701
|
+
import { Drainable } from '@soapjs/soap/events';
|
|
702
|
+
import { SocketMessage, SocketServer, WebSocketServerAdapter } from '@soapjs/soap-node-socket';
|
|
703
|
+
import { AppConfig } from '../../config/config';
|
|
704
|
+
import { socketHandlers } from '../../config/sockets';
|
|
705
|
+
|
|
706
|
+
export interface AppSocketHandler {
|
|
707
|
+
event: string;
|
|
708
|
+
handle(clientId: string, message: SocketMessage, server: SocketServer): Promise<void> | void;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export interface SocketRuntime extends Drainable {
|
|
712
|
+
server: SocketServer;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function createSocketRuntime(config: AppConfig, httpServer: Server): SocketRuntime {
|
|
716
|
+
const adapter = new WebSocketServerAdapter({ server: httpServer, path: config.wsPath });
|
|
717
|
+
const server = new SocketServer(adapter as any, {
|
|
718
|
+
port: config.port,
|
|
719
|
+
heartbeatInterval: config.wsHeartbeatMs,
|
|
720
|
+
onConnection: (clientId) => {
|
|
721
|
+
server.sendToClient(clientId, {
|
|
722
|
+
type: 'connected',
|
|
723
|
+
payload: { clientId },
|
|
724
|
+
});
|
|
725
|
+
},
|
|
726
|
+
onMessage: async (clientId, message) => {
|
|
727
|
+
const handler = socketHandlers.find((item) => item.event === message.type);
|
|
728
|
+
if (handler) {
|
|
729
|
+
await handler.handle(clientId, message, server);
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
server,
|
|
736
|
+
close: async () => server.shutdown(),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
`,
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
path: "src/common/sockets/ws.socket-server.ts",
|
|
743
|
+
type: "config",
|
|
744
|
+
content: `export { createSocketRuntime } from './socket.setup';
|
|
745
|
+
export type { AppSocketHandler, SocketRuntime } from './socket.setup';
|
|
746
|
+
`,
|
|
747
|
+
},
|
|
748
|
+
];
|
|
749
|
+
}
|
|
750
|
+
function createControllersTs(controllers) {
|
|
751
|
+
if (controllers.length === 0) {
|
|
752
|
+
return `export const controllers = [];
|
|
753
|
+
`;
|
|
754
|
+
}
|
|
755
|
+
const imports = controllers
|
|
756
|
+
.map((controller) => `import { ${controller.className} } from '${controller.importPath}';`)
|
|
757
|
+
.join("\n");
|
|
758
|
+
const names = controllers.map((controller) => ` ${controller.spread ? "..." : ""}${controller.className},`).join("\n");
|
|
759
|
+
return `${imports}
|
|
760
|
+
|
|
761
|
+
export const controllers = [
|
|
762
|
+
${names}
|
|
763
|
+
];
|
|
764
|
+
`;
|
|
765
|
+
}
|
|
766
|
+
exports.createControllersTs = createControllersTs;
|
|
767
|
+
function createInitialControllers(plan) {
|
|
768
|
+
if (!usesJwtAuth(plan)) {
|
|
769
|
+
return [];
|
|
770
|
+
}
|
|
771
|
+
return [
|
|
772
|
+
{
|
|
773
|
+
className: "AuthController",
|
|
774
|
+
importPath: "../features/auth/api/auth.controller",
|
|
775
|
+
},
|
|
776
|
+
];
|
|
777
|
+
}
|
|
778
|
+
function createEnvExample(plan) {
|
|
779
|
+
const lines = ["NODE_ENV=development", "PORT=3000", "LOG_LEVEL=debug"];
|
|
780
|
+
if (plan.capabilities.databases.includes("mongo")) {
|
|
781
|
+
lines.push("MONGO_URI=mongodb://localhost:27017/app");
|
|
782
|
+
}
|
|
783
|
+
if (plan.capabilities.databases.includes("postgres")) {
|
|
784
|
+
lines.push("POSTGRES_HOST=localhost", "POSTGRES_PORT=5432", "POSTGRES_DB=app", "POSTGRES_USER=app", "POSTGRES_PASSWORD=app");
|
|
785
|
+
}
|
|
786
|
+
if (plan.capabilities.databases.includes("mysql")) {
|
|
787
|
+
lines.push("MYSQL_HOST=localhost", "MYSQL_PORT=3306", "MYSQL_DATABASE=app", "MYSQL_USER=app", "MYSQL_PASSWORD=app");
|
|
788
|
+
}
|
|
789
|
+
if (plan.capabilities.databases.includes("sqlite")) {
|
|
790
|
+
lines.push("SQLITE_FILENAME=./data/app.sqlite");
|
|
791
|
+
}
|
|
792
|
+
if (plan.capabilities.databases.includes("redis")) {
|
|
793
|
+
lines.push("REDIS_URL=redis://localhost:6379");
|
|
794
|
+
}
|
|
795
|
+
if (plan.capabilities.auth.includes("jwt") || plan.capabilities.auth.includes("local")) {
|
|
796
|
+
lines.push("JWT_SECRET=change-me");
|
|
797
|
+
}
|
|
798
|
+
if (plan.capabilities.auth.includes("api-key")) {
|
|
799
|
+
lines.push("API_KEY_HEADER=x-api-key", "DEV_API_KEY=dev-api-key");
|
|
800
|
+
}
|
|
801
|
+
if (plan.capabilities.messaging.includes("kafka")) {
|
|
802
|
+
lines.push("KAFKA_BROKERS=localhost:9092", "EVENT_BUS=in-memory");
|
|
803
|
+
}
|
|
804
|
+
if (plan.capabilities.realtime.includes("ws")) {
|
|
805
|
+
lines.push("WS_PATH=/ws", "WS_HEARTBEAT_MS=30000");
|
|
806
|
+
}
|
|
807
|
+
return `${lines.join("\n")}\n`;
|
|
808
|
+
}
|
|
809
|
+
function createReadme(plan) {
|
|
810
|
+
const runCommand = plan.packageManager === "npm" ? "npm run dev" : `${plan.packageManager} dev`;
|
|
811
|
+
const buildCommand = plan.packageManager === "npm" ? "npm run build" : `${plan.packageManager} build`;
|
|
812
|
+
const installCommand = `${plan.packageManager} install`;
|
|
813
|
+
const dockerSection = `## Docker Development
|
|
814
|
+
|
|
815
|
+
\`\`\`bash
|
|
816
|
+
make up
|
|
817
|
+
make logs
|
|
818
|
+
curl http://localhost:3000/health
|
|
819
|
+
make down
|
|
820
|
+
\`\`\`
|
|
821
|
+
|
|
822
|
+
`;
|
|
823
|
+
const brunoSection = plan.capabilities.apiClient.includes("bruno")
|
|
824
|
+
? `## Bruno API Tests
|
|
825
|
+
|
|
826
|
+
\`\`\`bash
|
|
827
|
+
${plan.packageManager === "npm" ? "npm run bruno" : `${plan.packageManager} bruno`}
|
|
828
|
+
${plan.packageManager === "npm" ? "npm run test:api" : `${plan.packageManager} test:api`}
|
|
829
|
+
\`\`\`
|
|
830
|
+
|
|
831
|
+
The collection is generated in \`bruno/\` and uses the \`Local\` environment.
|
|
832
|
+
|
|
833
|
+
`
|
|
834
|
+
: "";
|
|
835
|
+
const openApiSection = plan.capabilities.docs.includes("openapi")
|
|
836
|
+
? `## OpenAPI
|
|
837
|
+
|
|
838
|
+
Start the API, then open:
|
|
839
|
+
|
|
840
|
+
- http://localhost:3000/docs
|
|
841
|
+
- http://localhost:3000/openapi.json
|
|
842
|
+
|
|
843
|
+
\`\`\`bash
|
|
844
|
+
curl http://localhost:3000/openapi.json
|
|
845
|
+
\`\`\`
|
|
846
|
+
|
|
847
|
+
`
|
|
848
|
+
: "";
|
|
849
|
+
const authSection = createReadmeAuthSection(plan);
|
|
850
|
+
const addCommands = `## Add More Code
|
|
851
|
+
|
|
852
|
+
\`\`\`bash
|
|
853
|
+
soap add resource invoice --crud
|
|
854
|
+
soap add route invoices export --method get --path export
|
|
855
|
+
soap update config --add-contracts zod
|
|
856
|
+
soap update config --add-api-client bruno
|
|
857
|
+
\`\`\`
|
|
858
|
+
`;
|
|
859
|
+
return `# ${plan.name}
|
|
860
|
+
|
|
861
|
+
Generated SoapJS service.
|
|
862
|
+
|
|
863
|
+
## Capabilities
|
|
864
|
+
|
|
865
|
+
- Framework: ${plan.framework}
|
|
866
|
+
- Architecture: ${plan.architecture}
|
|
867
|
+
- Databases: ${plan.capabilities.databases.length ? plan.capabilities.databases.join(", ") : "none"}
|
|
868
|
+
- Auth: ${plan.capabilities.auth.length ? plan.capabilities.auth.join(", ") : "none"}
|
|
869
|
+
- Messaging: ${plan.capabilities.messaging.join(", ")}
|
|
870
|
+
- Telemetry: ${plan.capabilities.telemetry.join(", ")}
|
|
871
|
+
- Realtime: ${plan.capabilities.realtime.length ? plan.capabilities.realtime.join(", ") : "none"}
|
|
872
|
+
- API client: ${plan.capabilities.apiClient.length ? plan.capabilities.apiClient.join(", ") : "none"}
|
|
873
|
+
- Docs: ${plan.capabilities.docs.length ? plan.capabilities.docs.join(", ") : "none"}
|
|
874
|
+
- Contracts: ${plan.capabilities.contracts.length ? plan.capabilities.contracts.join(", ") : "none"}
|
|
875
|
+
|
|
876
|
+
## Local Development
|
|
877
|
+
|
|
878
|
+
\`\`\`bash
|
|
879
|
+
${installCommand}
|
|
880
|
+
${runCommand}
|
|
881
|
+
\`\`\`
|
|
882
|
+
|
|
883
|
+
Health check:
|
|
884
|
+
|
|
885
|
+
\`\`\`bash
|
|
886
|
+
curl http://localhost:3000/health
|
|
887
|
+
\`\`\`
|
|
888
|
+
|
|
889
|
+
Build:
|
|
890
|
+
|
|
891
|
+
\`\`\`bash
|
|
892
|
+
${buildCommand}
|
|
893
|
+
\`\`\`
|
|
894
|
+
|
|
895
|
+
${dockerSection}${brunoSection}${openApiSection}${authSection}## Folder Structure
|
|
896
|
+
|
|
897
|
+
\`\`\`txt
|
|
898
|
+
src/
|
|
899
|
+
index.ts
|
|
900
|
+
config/
|
|
901
|
+
config.ts
|
|
902
|
+
controllers.ts
|
|
903
|
+
dependencies.ts
|
|
904
|
+
resources.ts
|
|
905
|
+
common/
|
|
906
|
+
features/
|
|
907
|
+
<resource>/
|
|
908
|
+
domain/
|
|
909
|
+
application/
|
|
910
|
+
data/
|
|
911
|
+
api/
|
|
912
|
+
contracts/
|
|
913
|
+
\`\`\`
|
|
914
|
+
|
|
915
|
+
Generated metadata is stored in \`.soap/\`.
|
|
916
|
+
|
|
917
|
+
${addCommands}
|
|
918
|
+
`;
|
|
919
|
+
}
|
|
920
|
+
function createReadmeAuthSection(plan) {
|
|
921
|
+
const sections = [];
|
|
922
|
+
if (plan.capabilities.auth.includes("jwt") || plan.capabilities.auth.includes("local")) {
|
|
923
|
+
sections.push(`### JWT/local auth dev credentials
|
|
924
|
+
|
|
925
|
+
Default Bruno login variables:
|
|
926
|
+
|
|
927
|
+
- email: \`admin@example.com\`
|
|
928
|
+
- password: \`admin123\`
|
|
929
|
+
|
|
930
|
+
Set \`JWT_SECRET\` in \`.env.example\` or your local \`.env\`.
|
|
931
|
+
|
|
932
|
+
`);
|
|
933
|
+
}
|
|
934
|
+
if (plan.capabilities.auth.includes("api-key")) {
|
|
935
|
+
sections.push(`### API key auth dev credentials
|
|
936
|
+
|
|
937
|
+
Default header and key:
|
|
938
|
+
|
|
939
|
+
- \`API_KEY_HEADER=x-api-key\`
|
|
940
|
+
- \`DEV_API_KEY=dev-api-key\`
|
|
941
|
+
|
|
942
|
+
`);
|
|
943
|
+
}
|
|
944
|
+
return sections.length > 0 ? `## Auth\n\n${sections.join("\n")}` : "";
|
|
945
|
+
}
|
|
946
|
+
function createDockerfile() {
|
|
947
|
+
return `FROM node:20-alpine
|
|
948
|
+
WORKDIR /app
|
|
949
|
+
COPY package*.json ./
|
|
950
|
+
RUN npm install
|
|
951
|
+
COPY . .
|
|
952
|
+
RUN npm run build
|
|
953
|
+
CMD ["npm", "start"]
|
|
954
|
+
`;
|
|
955
|
+
}
|
|
956
|
+
function createDockerCompose(plan) {
|
|
957
|
+
const services = [
|
|
958
|
+
"services:",
|
|
959
|
+
" api:",
|
|
960
|
+
" build: .",
|
|
961
|
+
" ports:",
|
|
962
|
+
" - \"3000:3000\"",
|
|
963
|
+
" env_file:",
|
|
964
|
+
" - .env.example",
|
|
965
|
+
];
|
|
966
|
+
if (plan.capabilities.databases.includes("mongo")) {
|
|
967
|
+
services.push(" mongo:", " image: mongo:7", " ports:", " - \"27017:27017\"", " volumes:", " - mongo-data:/data/db");
|
|
968
|
+
}
|
|
969
|
+
if (plan.capabilities.databases.includes("postgres")) {
|
|
970
|
+
services.push(" postgres:", " image: postgres:16", " environment:", " POSTGRES_DB: app", " POSTGRES_USER: app", " POSTGRES_PASSWORD: app", " ports:", " - \"5432:5432\"", " volumes:", " - postgres-data:/var/lib/postgresql/data");
|
|
971
|
+
}
|
|
972
|
+
if (plan.capabilities.databases.includes("mysql")) {
|
|
973
|
+
services.push(" mysql:", " image: mysql:8", " environment:", " MYSQL_DATABASE: app", " MYSQL_USER: app", " MYSQL_PASSWORD: app", " MYSQL_ROOT_PASSWORD: app", " ports:", " - \"3306:3306\"", " volumes:", " - mysql-data:/var/lib/mysql");
|
|
974
|
+
}
|
|
975
|
+
if (plan.capabilities.databases.includes("redis")) {
|
|
976
|
+
services.push(" redis:", " image: redis:7", " ports:", " - \"6379:6379\"");
|
|
977
|
+
}
|
|
978
|
+
if (plan.capabilities.messaging.includes("kafka")) {
|
|
979
|
+
services.push(" redpanda:", " image: redpandadata/redpanda:v24.1.1", " command: redpanda start --overprovisioned --smp 1 --memory 512M --reserve-memory 0M --node-id 0 --check=false", " ports:", " - \"9092:9092\"");
|
|
980
|
+
}
|
|
981
|
+
const volumes = ["volumes:"];
|
|
982
|
+
if (plan.capabilities.databases.includes("mongo"))
|
|
983
|
+
volumes.push(" mongo-data:");
|
|
984
|
+
if (plan.capabilities.databases.includes("postgres"))
|
|
985
|
+
volumes.push(" postgres-data:");
|
|
986
|
+
if (plan.capabilities.databases.includes("mysql"))
|
|
987
|
+
volumes.push(" mysql-data:");
|
|
988
|
+
return `${services.join("\n")}\n${volumes.length > 1 ? volumes.join("\n") : ""}\n`;
|
|
989
|
+
}
|
|
990
|
+
function createMakefile() {
|
|
991
|
+
return `.PHONY: help up down down-clean logs build dev test lint bruno test-api openapi kafka-mode
|
|
992
|
+
|
|
993
|
+
help:
|
|
994
|
+
\t@grep -E '^[a-zA-Z_-]+:' Makefile
|
|
995
|
+
|
|
996
|
+
up:
|
|
997
|
+
\tdocker compose up -d --build
|
|
998
|
+
|
|
999
|
+
down:
|
|
1000
|
+
\tdocker compose down
|
|
1001
|
+
|
|
1002
|
+
down-clean:
|
|
1003
|
+
\tdocker compose down -v
|
|
1004
|
+
|
|
1005
|
+
logs:
|
|
1006
|
+
\tdocker compose logs -f api
|
|
1007
|
+
|
|
1008
|
+
build:
|
|
1009
|
+
\tnpm run build
|
|
1010
|
+
|
|
1011
|
+
dev:
|
|
1012
|
+
\tnpm run dev
|
|
1013
|
+
|
|
1014
|
+
test:
|
|
1015
|
+
\tnpm test
|
|
1016
|
+
|
|
1017
|
+
lint:
|
|
1018
|
+
\tnpm run lint
|
|
1019
|
+
|
|
1020
|
+
bruno:
|
|
1021
|
+
\tnpm run bruno
|
|
1022
|
+
|
|
1023
|
+
test-api:
|
|
1024
|
+
\tnpm run test:api
|
|
1025
|
+
|
|
1026
|
+
openapi:
|
|
1027
|
+
\tcurl http://localhost:3000/openapi.json
|
|
1028
|
+
|
|
1029
|
+
kafka-mode:
|
|
1030
|
+
\tEVENT_BUS=kafka npm run dev
|
|
1031
|
+
`;
|
|
1032
|
+
}
|
|
1033
|
+
function createAuthFiles(plan) {
|
|
1034
|
+
if (plan.capabilities.auth.length === 0) {
|
|
1035
|
+
return [];
|
|
1036
|
+
}
|
|
1037
|
+
const files = [
|
|
1038
|
+
{
|
|
1039
|
+
path: "src/features/auth/domain/auth-user.ts",
|
|
1040
|
+
type: "config",
|
|
1041
|
+
owner: "auth",
|
|
1042
|
+
content: `export interface AuthUser {
|
|
1043
|
+
id: string;
|
|
1044
|
+
email: string;
|
|
1045
|
+
name: string;
|
|
1046
|
+
roles: string[];
|
|
1047
|
+
}
|
|
1048
|
+
`,
|
|
1049
|
+
},
|
|
1050
|
+
];
|
|
1051
|
+
if (usesJwtAuth(plan)) {
|
|
1052
|
+
files.push({
|
|
1053
|
+
path: "src/features/auth/data/dev-users.ts",
|
|
1054
|
+
type: "config",
|
|
1055
|
+
owner: "auth",
|
|
1056
|
+
content: `import { AuthUser } from '../domain/auth-user';
|
|
1057
|
+
|
|
1058
|
+
export interface DevUser extends AuthUser {
|
|
1059
|
+
password: string;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
export const devUsers: DevUser[] = [
|
|
1063
|
+
{
|
|
1064
|
+
id: 'dev-admin',
|
|
1065
|
+
email: 'admin@example.com',
|
|
1066
|
+
name: 'Dev Admin',
|
|
1067
|
+
password: 'admin123',
|
|
1068
|
+
roles: ['admin'],
|
|
1069
|
+
},
|
|
1070
|
+
];
|
|
1071
|
+
|
|
1072
|
+
export function findDevUser(email: string): DevUser | undefined {
|
|
1073
|
+
return devUsers.find((user) => user.email === email);
|
|
1074
|
+
}
|
|
1075
|
+
`,
|
|
1076
|
+
}, {
|
|
1077
|
+
path: "src/features/auth/auth.tokens.ts",
|
|
1078
|
+
type: "config",
|
|
1079
|
+
owner: "auth",
|
|
1080
|
+
content: `import jwt from 'jsonwebtoken';
|
|
1081
|
+
import { AuthUser } from './domain/auth-user';
|
|
1082
|
+
|
|
1083
|
+
export interface AuthTokenPayload {
|
|
1084
|
+
sub: string;
|
|
1085
|
+
email: string;
|
|
1086
|
+
name: string;
|
|
1087
|
+
roles: string[];
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export function signAccessToken(user: AuthUser, secret: string): string {
|
|
1091
|
+
return jwt.sign(
|
|
1092
|
+
{
|
|
1093
|
+
sub: user.id,
|
|
1094
|
+
email: user.email,
|
|
1095
|
+
name: user.name,
|
|
1096
|
+
roles: user.roles,
|
|
1097
|
+
},
|
|
1098
|
+
secret,
|
|
1099
|
+
{ expiresIn: '1h' }
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
export function verifyAccessToken(token: string, secret: string): AuthUser {
|
|
1104
|
+
const payload = jwt.verify(token, secret) as AuthTokenPayload;
|
|
1105
|
+
|
|
1106
|
+
return {
|
|
1107
|
+
id: payload.sub,
|
|
1108
|
+
email: payload.email,
|
|
1109
|
+
name: payload.name,
|
|
1110
|
+
roles: payload.roles ?? [],
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
`,
|
|
1114
|
+
}, {
|
|
1115
|
+
path: "src/features/auth/jwt.strategy.ts",
|
|
1116
|
+
type: "config",
|
|
1117
|
+
owner: "auth",
|
|
1118
|
+
content: `import { AuthStrategy, HttpContext } from '@soapjs/soap/http';
|
|
1119
|
+
import { verifyAccessToken } from './auth.tokens';
|
|
1120
|
+
import { AuthUser } from './domain/auth-user';
|
|
1121
|
+
|
|
1122
|
+
export class JwtAuthStrategy implements AuthStrategy<AuthUser> {
|
|
1123
|
+
readonly name = 'jwt';
|
|
1124
|
+
|
|
1125
|
+
constructor(private readonly secret: string) {}
|
|
1126
|
+
|
|
1127
|
+
async authenticate(ctx: HttpContext) {
|
|
1128
|
+
const authorization = ctx.req.headers.authorization;
|
|
1129
|
+
const header = Array.isArray(authorization) ? authorization[0] : authorization;
|
|
1130
|
+
|
|
1131
|
+
if (!header?.startsWith('Bearer ')) {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const token = header.slice('Bearer '.length);
|
|
1136
|
+
const user = verifyAccessToken(token, this.secret);
|
|
1137
|
+
|
|
1138
|
+
return { user };
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
`,
|
|
1142
|
+
}, {
|
|
1143
|
+
path: "src/features/auth/api/auth.controller.ts",
|
|
1144
|
+
type: "config",
|
|
1145
|
+
owner: "auth",
|
|
1146
|
+
content: `import { Request } from 'express';
|
|
1147
|
+
import { Auth, Controller, Get, Post } from '@soapjs/soap-express';
|
|
1148
|
+
import { config } from '../../../config/config';
|
|
1149
|
+
import { signAccessToken } from '../auth.tokens';
|
|
1150
|
+
import { findDevUser } from '../data/dev-users';
|
|
1151
|
+
|
|
1152
|
+
@Controller('/auth', {
|
|
1153
|
+
apiDoc: {
|
|
1154
|
+
tags: ['Auth'],
|
|
1155
|
+
description: 'Development auth endpoints generated by SoapJS CLI',
|
|
1156
|
+
},
|
|
1157
|
+
})
|
|
1158
|
+
export class AuthController {
|
|
1159
|
+
@Post('/login')
|
|
1160
|
+
async login(req: Request): Promise<unknown> {
|
|
1161
|
+
const { email, password } = req.body ?? {};
|
|
1162
|
+
const user = typeof email === 'string' ? findDevUser(email) : undefined;
|
|
1163
|
+
|
|
1164
|
+
if (!user || user.password !== password) {
|
|
1165
|
+
return { error: 'Invalid credentials' };
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const { password: _password, ...safeUser } = user;
|
|
1169
|
+
const accessToken = signAccessToken(safeUser, config.jwtSecret);
|
|
1170
|
+
|
|
1171
|
+
return {
|
|
1172
|
+
accessToken,
|
|
1173
|
+
user: safeUser,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
@Get('/me')
|
|
1178
|
+
@Auth('jwt')
|
|
1179
|
+
async me(req: Request): Promise<unknown> {
|
|
1180
|
+
return (req as Request & { user?: unknown }).user;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
`,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
if (plan.capabilities.auth.includes("api-key")) {
|
|
1187
|
+
files.push({
|
|
1188
|
+
path: "src/features/auth/api-key.strategy.ts",
|
|
1189
|
+
type: "config",
|
|
1190
|
+
owner: "auth",
|
|
1191
|
+
content: `import { AuthStrategy, HttpContext } from '@soapjs/soap/http';
|
|
1192
|
+
import { AuthUser } from './domain/auth-user';
|
|
1193
|
+
|
|
1194
|
+
export class ApiKeyAuthStrategy implements AuthStrategy<AuthUser> {
|
|
1195
|
+
readonly name = 'api-key';
|
|
1196
|
+
|
|
1197
|
+
constructor(
|
|
1198
|
+
private readonly headerName: string,
|
|
1199
|
+
private readonly expectedApiKey: string
|
|
1200
|
+
) {}
|
|
1201
|
+
|
|
1202
|
+
async authenticate(ctx: HttpContext) {
|
|
1203
|
+
const value = ctx.req.headers[this.headerName.toLowerCase()];
|
|
1204
|
+
const apiKey = Array.isArray(value) ? value[0] : value;
|
|
1205
|
+
|
|
1206
|
+
if (!apiKey || apiKey !== this.expectedApiKey) {
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
return {
|
|
1211
|
+
user: {
|
|
1212
|
+
id: 'api-key-client',
|
|
1213
|
+
email: 'api-key@example.com',
|
|
1214
|
+
name: 'API Key Client',
|
|
1215
|
+
roles: ['admin'],
|
|
1216
|
+
},
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
`,
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
files.push({
|
|
1224
|
+
path: "src/features/auth/auth.setup.ts",
|
|
1225
|
+
type: "config",
|
|
1226
|
+
owner: "auth",
|
|
1227
|
+
content: createAuthSetupTs(plan),
|
|
1228
|
+
}, {
|
|
1229
|
+
path: "src/features/auth/index.ts",
|
|
1230
|
+
type: "config",
|
|
1231
|
+
owner: "auth",
|
|
1232
|
+
content: createAuthIndexTs(plan),
|
|
1233
|
+
});
|
|
1234
|
+
return files;
|
|
1235
|
+
}
|
|
1236
|
+
function usesJwtAuth(plan) {
|
|
1237
|
+
return plan.capabilities.auth.includes("jwt") || plan.capabilities.auth.includes("local");
|
|
1238
|
+
}
|
|
1239
|
+
function createAuthSetupTs(plan) {
|
|
1240
|
+
const imports = [
|
|
1241
|
+
"import { AuthStrategy } from '@soapjs/soap/http';",
|
|
1242
|
+
"import { AppConfig } from '../../config/config';",
|
|
1243
|
+
];
|
|
1244
|
+
const body = [" const strategies: AuthStrategy[] = [];"];
|
|
1245
|
+
if (usesJwtAuth(plan)) {
|
|
1246
|
+
imports.push("import { JwtAuthStrategy } from './jwt.strategy';");
|
|
1247
|
+
body.push("", " strategies.push(new JwtAuthStrategy(config.jwtSecret));");
|
|
1248
|
+
}
|
|
1249
|
+
if (plan.capabilities.auth.includes("api-key")) {
|
|
1250
|
+
imports.push("import { ApiKeyAuthStrategy } from './api-key.strategy';");
|
|
1251
|
+
body.push("", " strategies.push(new ApiKeyAuthStrategy(config.apiKeyHeader, config.devApiKey));");
|
|
1252
|
+
}
|
|
1253
|
+
body.push("", " return strategies;");
|
|
1254
|
+
return `${imports.join("\n")}
|
|
1255
|
+
|
|
1256
|
+
export function createAuthStrategies(config: AppConfig): AuthStrategy[] {
|
|
1257
|
+
${body.join("\n")}
|
|
1258
|
+
}
|
|
1259
|
+
`;
|
|
1260
|
+
}
|
|
1261
|
+
function createAuthIndexTs(plan) {
|
|
1262
|
+
const exports = ["export * from './auth.setup';", "export * from './domain/auth-user';"];
|
|
1263
|
+
if (usesJwtAuth(plan)) {
|
|
1264
|
+
exports.push("export * from './api/auth.controller';", "export * from './auth.tokens';", "export * from './jwt.strategy';");
|
|
1265
|
+
}
|
|
1266
|
+
if (plan.capabilities.auth.includes("api-key")) {
|
|
1267
|
+
exports.push("export * from './api-key.strategy';");
|
|
1268
|
+
}
|
|
1269
|
+
return `${exports.join("\n")}\n`;
|
|
1270
|
+
}
|
|
1271
|
+
function createFeaturesIndexTs(plan) {
|
|
1272
|
+
const exports = [];
|
|
1273
|
+
if (plan.capabilities.auth.length > 0) {
|
|
1274
|
+
exports.push("export * from './auth';");
|
|
1275
|
+
}
|
|
1276
|
+
return exports.length > 0 ? `${exports.join("\n")}\n` : "export {};\n";
|
|
1277
|
+
}
|
|
1278
|
+
function createBrunoFiles(plan) {
|
|
1279
|
+
if (!plan.capabilities.apiClient.includes("bruno")) {
|
|
1280
|
+
return [];
|
|
1281
|
+
}
|
|
1282
|
+
const files = [
|
|
1283
|
+
{
|
|
1284
|
+
path: "bruno/bruno.json",
|
|
1285
|
+
type: "bruno",
|
|
1286
|
+
content: JSON.stringify({ version: "1", name: plan.name, type: "collection" }, null, 2),
|
|
1287
|
+
},
|
|
1288
|
+
{
|
|
1289
|
+
path: "bruno/Health/health.bru",
|
|
1290
|
+
type: "bruno",
|
|
1291
|
+
content: `meta {
|
|
1292
|
+
name: Health
|
|
1293
|
+
type: http
|
|
1294
|
+
seq: 1
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
get {
|
|
1298
|
+
url: {{baseUrl}}/health
|
|
1299
|
+
body: none
|
|
1300
|
+
auth: none
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
tests {
|
|
1304
|
+
function onResponse(request, response) {
|
|
1305
|
+
expect(response.status).to.be.within(200, 299);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
`,
|
|
1309
|
+
},
|
|
1310
|
+
{
|
|
1311
|
+
path: "bruno/environments/Local.bru",
|
|
1312
|
+
type: "bruno",
|
|
1313
|
+
content: `vars {
|
|
1314
|
+
baseUrl: http://localhost:3000
|
|
1315
|
+
accessToken:
|
|
1316
|
+
apiKey:
|
|
1317
|
+
id:
|
|
1318
|
+
email: admin@example.com
|
|
1319
|
+
password: admin123
|
|
1320
|
+
}
|
|
1321
|
+
`,
|
|
1322
|
+
},
|
|
1323
|
+
];
|
|
1324
|
+
if (usesJwtAuth(plan)) {
|
|
1325
|
+
files.push({
|
|
1326
|
+
path: "bruno/Auth/login.bru",
|
|
1327
|
+
type: "bruno",
|
|
1328
|
+
owner: "auth",
|
|
1329
|
+
content: createBrunoLoginBru(2),
|
|
1330
|
+
}, {
|
|
1331
|
+
path: "bruno/Auth/me.bru",
|
|
1332
|
+
type: "bruno",
|
|
1333
|
+
owner: "auth",
|
|
1334
|
+
content: createBrunoRequestBru({
|
|
1335
|
+
name: "Me",
|
|
1336
|
+
method: "GET",
|
|
1337
|
+
path: "/auth/me",
|
|
1338
|
+
sequence: 3,
|
|
1339
|
+
auth: "jwt",
|
|
1340
|
+
}),
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
return files;
|
|
1344
|
+
}
|
|
1345
|
+
function createBrunoLoginBru(sequence) {
|
|
1346
|
+
return `meta {
|
|
1347
|
+
name: Login
|
|
1348
|
+
type: http
|
|
1349
|
+
seq: ${sequence}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
post {
|
|
1353
|
+
url: {{baseUrl}}/auth/login
|
|
1354
|
+
body: json
|
|
1355
|
+
auth: none
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
body:json {
|
|
1359
|
+
{
|
|
1360
|
+
"email": "{{email}}",
|
|
1361
|
+
"password": "{{password}}"
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
script:post-response {
|
|
1366
|
+
const data = res.getBody();
|
|
1367
|
+
if (data?.accessToken) {
|
|
1368
|
+
bru.setEnvVar("accessToken", data.accessToken);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
tests {
|
|
1373
|
+
function onResponse(request, response) {
|
|
1374
|
+
expect(response.status).to.be.within(200, 299);
|
|
1375
|
+
const body = response.getBody();
|
|
1376
|
+
expect(body.accessToken).to.be.a('string').and.not.empty;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
`;
|
|
1380
|
+
}
|
|
1381
|
+
function createBrunoRequestBru(options) {
|
|
1382
|
+
const method = options.method.toLowerCase();
|
|
1383
|
+
const bodyType = options.includeJsonBody ? "json" : "none";
|
|
1384
|
+
const authBlock = options.auth === "jwt"
|
|
1385
|
+
? `
|
|
1386
|
+
headers {
|
|
1387
|
+
Authorization: Bearer {{accessToken}}
|
|
1388
|
+
}
|
|
1389
|
+
`
|
|
1390
|
+
: options.auth === "api-key"
|
|
1391
|
+
? `
|
|
1392
|
+
headers {
|
|
1393
|
+
x-api-key: {{apiKey}}
|
|
1394
|
+
}
|
|
1395
|
+
`
|
|
1396
|
+
: "";
|
|
1397
|
+
const bodyBlock = options.includeJsonBody
|
|
1398
|
+
? `
|
|
1399
|
+
body:json {
|
|
1400
|
+
{
|
|
1401
|
+
"name": "Example"
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
`
|
|
1405
|
+
: "";
|
|
1406
|
+
const bodyExpectation = options.expectBody
|
|
1407
|
+
? `
|
|
1408
|
+
expect(response.getBody()).to.exist;
|
|
1409
|
+
`
|
|
1410
|
+
: "";
|
|
1411
|
+
const testsBlock = `
|
|
1412
|
+
tests {
|
|
1413
|
+
function onResponse(request, response) {
|
|
1414
|
+
expect(response.status).to.be.within(200, 299);${bodyExpectation}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
`;
|
|
1418
|
+
return `meta {
|
|
1419
|
+
name: ${options.name}
|
|
1420
|
+
type: http
|
|
1421
|
+
seq: ${options.sequence}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
${method} {
|
|
1425
|
+
url: {{baseUrl}}${options.path}
|
|
1426
|
+
body: ${bodyType}
|
|
1427
|
+
auth: none
|
|
1428
|
+
}
|
|
1429
|
+
${authBlock}${bodyBlock}${testsBlock}`;
|
|
1430
|
+
}
|