@open-rlb/nestjs-amqp 2.0.4 → 2.0.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/README.md +4 -2
- package/amqp-lib/config/rabbitmq.config.d.ts +6 -0
- package/modules/acl/const.d.ts +0 -1
- package/modules/acl/const.js +0 -1
- package/modules/acl/const.js.map +1 -1
- package/modules/acl/models.d.ts +5 -7
- package/modules/acl/repository/acl-action.repository.d.ts +1 -5
- package/modules/acl/repository/acl-action.repository.js.map +1 -1
- package/modules/acl/repository/acl-role.repository.d.ts +1 -5
- package/modules/acl/repository/acl-role.repository.js.map +1 -1
- package/modules/acl/services/acl-management.service.d.ts +2 -2
- package/modules/acl/services/acl-management.service.js +17 -20
- package/modules/acl/services/acl-management.service.js.map +1 -1
- package/modules/acl/services/acl.service.d.ts +1 -2
- package/modules/acl/services/acl.service.js +5 -21
- package/modules/acl/services/acl.service.js.map +1 -1
- package/modules/broker/broker.module.d.ts +2 -4
- package/modules/broker/broker.module.js +23 -5
- package/modules/broker/broker.module.js.map +1 -1
- package/modules/broker/config/route-discovery.config.d.ts +2 -0
- package/modules/broker/const.d.ts +1 -0
- package/modules/broker/const.js +2 -1
- package/modules/broker/const.js.map +1 -1
- package/modules/broker/services/broker.service.js +1 -1
- package/modules/broker/services/broker.service.js.map +1 -1
- package/modules/broker/services/route-discovery-publisher.service.js +7 -5
- package/modules/broker/services/route-discovery-publisher.service.js.map +1 -1
- package/modules/gateway-admin/config/gateway-admin.config.d.ts +4 -0
- package/modules/gateway-admin/const.d.ts +1 -1
- package/modules/gateway-admin/const.js +1 -1
- package/modules/gateway-admin/const.js.map +1 -1
- package/modules/gateway-admin/gateway-admin.module.d.ts +7 -1
- package/modules/gateway-admin/gateway-admin.module.js +13 -0
- package/modules/gateway-admin/gateway-admin.module.js.map +1 -1
- package/modules/gateway-admin/repository/auth-provider.repository.d.ts +3 -4
- package/modules/gateway-admin/repository/auth-provider.repository.js.map +1 -1
- package/modules/gateway-admin/services/gateway-auth.service.d.ts +3 -4
- package/modules/gateway-admin/services/gateway-auth.service.js +15 -23
- package/modules/gateway-admin/services/gateway-auth.service.js.map +1 -1
- package/modules/gateway-admin/services/gateway-metrics.service.d.ts +3 -0
- package/modules/gateway-admin/services/gateway-metrics.service.js +9 -0
- package/modules/gateway-admin/services/gateway-metrics.service.js.map +1 -1
- package/modules/gateway-admin/services/route-sync.service.d.ts +3 -1
- package/modules/gateway-admin/services/route-sync.service.js +14 -8
- package/modules/gateway-admin/services/route-sync.service.js.map +1 -1
- package/modules/proxy/services/http-handler.service.d.ts +3 -0
- package/modules/proxy/services/http-handler.service.js +27 -3
- package/modules/proxy/services/http-handler.service.js.map +1 -1
- package/package.json +5 -1
- package/schematics/nest-add/files/acl/src/cache/in-memory-acl-store.ts +54 -0
- package/schematics/nest-add/files/acl/src/modules/database/repository/acl.repository.ts +74 -0
- package/schematics/nest-add/files/db-core/src/modules/database/repository/in-memory-collection.ts +122 -0
- package/schematics/nest-add/files/gateway-admin/src/modules/database/repository/gateway.repository.ts +151 -0
- package/schematics/nest-add/files/gateway-admin/src/modules/database/repository/route-sync.repository.ts +15 -0
- package/schematics/nest-add/files/skills/rlb-amqp/SKILL.md +30 -5
- package/schematics/nest-add/files/skills/rlb-amqp/references/config-schema.md +182 -93
- package/schematics/nest-add/files/skills/rlb-amqp/references/gotchas.md +120 -79
- package/schematics/nest-add/files/skills/rlb-amqp-acl/SKILL.md +185 -0
- package/schematics/nest-add/files/skills/rlb-amqp-add-action/SKILL.md +49 -2
- package/schematics/nest-add/files/skills/rlb-amqp-add-route/SKILL.md +82 -19
- package/schematics/nest-add/files/skills/rlb-amqp-add-ws-event/SKILL.md +40 -18
- package/schematics/nest-add/files/skills/rlb-amqp-gateway-admin/SKILL.md +233 -0
- package/schematics/nest-add/files/skills/rlb-amqp-scaffold/SKILL.md +172 -42
- package/schematics/nest-add/index.js +612 -142
- package/schematics/nest-add/index.js.map +1 -1
- package/schematics/nest-add/index.ts +673 -241
- package/schematics/nest-add/init.schema.d.ts +10 -1
- package/schematics/nest-add/init.schema.ts +29 -3
- package/schematics/nest-add/schema.json +37 -8
|
@@ -9,52 +9,234 @@ import { InitOptions } from './init.schema';
|
|
|
9
9
|
type UpdateJsonFn<T> = (obj: T) => T | void;
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
|
-
//
|
|
12
|
+
// Resolved selections (from interactive prompts or flags/defaults)
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return broker + config + http;
|
|
15
|
+
interface Names {
|
|
16
|
+
exchange: string; // main AMQP exchange backing acl/admin queues (default rlb)
|
|
17
|
+
aclQueue: string; // queue backing the fixed rlb-acl topic
|
|
18
|
+
adminQueue: string; // queue backing the fixed rlb-gateway-admin topic
|
|
19
|
+
controlTopic: string; // broadcast control/reload topic
|
|
20
|
+
routeExchange: string; // route-discovery fanout exchange
|
|
21
|
+
routeQueue: string; // route-sync durable queue
|
|
22
|
+
serviceName: string; // route-publish ownership + connection_name
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})
|
|
35
|
-
})`;
|
|
25
|
+
interface Resolved {
|
|
26
|
+
gatewayConfig: boolean; // create the HTTP/WebSocket gateway config
|
|
27
|
+
acl: boolean; // AclModule + ACL paths
|
|
28
|
+
admin: boolean; // GatewayAdminModule + DB routes/auth/metrics paths
|
|
29
|
+
routeReception: boolean;// gateway consumes routes auto-published by microservices
|
|
30
|
+
autoPublish: boolean; // microservice publishes its @BrokerHTTP routes on boot
|
|
31
|
+
skills: boolean; // copy the Claude skills
|
|
32
|
+
names: Names;
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
function defaultNames(project: string): Names {
|
|
36
|
+
return {
|
|
37
|
+
exchange: 'rlb',
|
|
38
|
+
aclQueue: 'rlb-acl',
|
|
39
|
+
adminQueue: 'rlb-gateway-admin',
|
|
40
|
+
controlTopic: 'rlb-gateway-control',
|
|
41
|
+
routeExchange: 'rlb-route-discovery',
|
|
42
|
+
routeQueue: 'rlb-route-sync',
|
|
43
|
+
serviceName: project || 'my-service',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolves the install selections. When stdin is a TTY and no flags were passed, it drives a
|
|
49
|
+
* branching interactive flow with @inquirer/prompts:
|
|
50
|
+
* - "Create a gateway configuration?" → yes: pick {acl, gateway-admin, route-reception} and the
|
|
51
|
+
* topic/queue/exchange names (rlb-* defaults); no: pick {auto-config-publish} (+ names).
|
|
52
|
+
* Otherwise it falls back to the provided flags + rlb-* defaults (non-interactive / CI).
|
|
53
|
+
*/
|
|
54
|
+
async function resolveSelections(o: InitOptions, context: SchematicContext): Promise<Resolved> {
|
|
55
|
+
const project = (o.project || 'my-service').toString();
|
|
56
|
+
const d = defaultNames(project);
|
|
57
|
+
const flagsProvided = o.gatewayConfig !== undefined || (Array.isArray(o.features) && o.features.length > 0);
|
|
58
|
+
const canPrompt = !!process.stdout && !!process.stdout.isTTY && !process.env.CI && !flagsProvided;
|
|
59
|
+
|
|
60
|
+
let prompts: any;
|
|
61
|
+
if (canPrompt) {
|
|
62
|
+
try {
|
|
63
|
+
prompts = require('@inquirer/prompts');
|
|
64
|
+
} catch {
|
|
65
|
+
context.logger.warn('[nest-add] @inquirer/prompts not found; falling back to flags/defaults (non-interactive).');
|
|
66
|
+
prompts = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- Non-interactive: derive everything from flags + defaults --------------
|
|
71
|
+
if (!prompts) {
|
|
72
|
+
const features = new Set((o.features || []).map((f) => String(f).trim()));
|
|
73
|
+
const gatewayConfig = o.gatewayConfig === true;
|
|
74
|
+
return {
|
|
75
|
+
gatewayConfig,
|
|
76
|
+
acl: gatewayConfig && features.has('acl'),
|
|
77
|
+
admin: gatewayConfig && features.has('gateway-admin'),
|
|
78
|
+
routeReception: gatewayConfig && features.has('route-reception'),
|
|
79
|
+
autoPublish: !gatewayConfig && features.has('auto-config-publish'),
|
|
80
|
+
skills: o.skills !== false,
|
|
81
|
+
names: {
|
|
82
|
+
exchange: o.exchange || d.exchange,
|
|
83
|
+
aclQueue: o.aclQueue || d.aclQueue,
|
|
84
|
+
adminQueue: o.adminQueue || d.adminQueue,
|
|
85
|
+
controlTopic: o.controlTopic || d.controlTopic,
|
|
86
|
+
routeExchange: o.routeExchange || d.routeExchange,
|
|
87
|
+
routeQueue: o.routeQueue || d.routeQueue,
|
|
88
|
+
serviceName: o.serviceName || d.serviceName,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Interactive branching flow -------------------------------------------
|
|
94
|
+
const { confirm, checkbox, input } = prompts;
|
|
95
|
+
const names: Names = { ...d };
|
|
96
|
+
let acl = false, admin = false, routeReception = false, autoPublish = false;
|
|
97
|
+
|
|
98
|
+
const gatewayConfig: boolean = await confirm({
|
|
99
|
+
message: 'Create a gateway (HTTP/WebSocket) configuration?',
|
|
100
|
+
default: false,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (gatewayConfig) {
|
|
104
|
+
const picked: string[] = await checkbox({
|
|
105
|
+
message: 'Select gateway features to include',
|
|
106
|
+
choices: [
|
|
107
|
+
{ name: 'ACL — role-based authorization + management', value: 'acl' },
|
|
108
|
+
{ name: 'Gateway admin + auth — DB-managed routes, auth-providers, metrics', value: 'gateway-admin' },
|
|
109
|
+
{ name: 'Route reception — apply routes auto-published by microservices', value: 'route-reception' },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
acl = picked.includes('acl');
|
|
113
|
+
admin = picked.includes('gateway-admin');
|
|
114
|
+
routeReception = picked.includes('route-reception');
|
|
115
|
+
|
|
116
|
+
if (acl || admin || routeReception) {
|
|
117
|
+
names.exchange = await input({ message: 'Main AMQP exchange name', default: d.exchange });
|
|
118
|
+
}
|
|
119
|
+
if (acl) {
|
|
120
|
+
names.aclQueue = await input({ message: 'Queue backing the rlb-acl topic', default: d.aclQueue });
|
|
121
|
+
}
|
|
122
|
+
if (admin || routeReception) {
|
|
123
|
+
names.adminQueue = await input({ message: 'Queue backing the rlb-gateway-admin topic', default: d.adminQueue });
|
|
124
|
+
names.controlTopic = await input({ message: 'Broadcast control/reload topic name', default: d.controlTopic });
|
|
125
|
+
}
|
|
126
|
+
if (routeReception) {
|
|
127
|
+
names.routeExchange = await input({ message: 'Route-discovery exchange (must match the publishers)', default: d.routeExchange });
|
|
128
|
+
names.routeQueue = await input({ message: 'Route-sync queue', default: d.routeQueue });
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
const picked: string[] = await checkbox({
|
|
132
|
+
message: 'Select microservice features to include',
|
|
133
|
+
choices: [
|
|
134
|
+
{ name: 'Auto-send config at startup — publish this service’s @BrokerHTTP routes to the gateway on boot', value: 'auto-config-publish' },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
autoPublish = picked.includes('auto-config-publish');
|
|
138
|
+
if (autoPublish) {
|
|
139
|
+
names.serviceName = await input({ message: 'Service name (route ownership + AMQP connection_name)', default: d.serviceName });
|
|
140
|
+
names.routeExchange = await input({ message: 'Route-discovery exchange', default: d.routeExchange });
|
|
141
|
+
names.routeQueue = await input({ message: 'Route-sync queue', default: d.routeQueue });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const skills: boolean = await confirm({ message: 'Copy the Claude skills into .claude/skills?', default: true });
|
|
146
|
+
return { gatewayConfig, acl, admin, routeReception, autoPublish, skills, names };
|
|
50
147
|
}
|
|
51
148
|
|
|
52
149
|
// ---------------------------------------------------------------------------
|
|
53
|
-
//
|
|
150
|
+
// config/config.yaml builder
|
|
54
151
|
// ---------------------------------------------------------------------------
|
|
55
152
|
|
|
56
|
-
|
|
57
|
-
|
|
153
|
+
function buildConfigYaml(sel: Resolved): string {
|
|
154
|
+
const n = sel.names;
|
|
155
|
+
const anyAdmin = sel.admin || sel.routeReception;
|
|
156
|
+
|
|
157
|
+
const routeDiscoveryPub = sel.autoPublish ? `
|
|
158
|
+
# Route auto-discovery (publisher side): announce this service's @BrokerHTTP routes on boot.
|
|
159
|
+
# serviceName also fills the AMQP connection_name (none is set explicitly below).
|
|
160
|
+
routeDiscovery:
|
|
161
|
+
serviceName: "${n.serviceName}"
|
|
162
|
+
publishOnBoot: true
|
|
163
|
+
exchange: ${n.routeExchange}
|
|
164
|
+
queue: ${n.routeQueue}` : '';
|
|
165
|
+
|
|
166
|
+
const clientProps = sel.autoPublish ? '' : `
|
|
167
|
+
clientProperties:
|
|
168
|
+
connection_name: "<APP_NAME>"`;
|
|
169
|
+
|
|
170
|
+
const exchanges: string[] = [];
|
|
171
|
+
if (sel.acl || anyAdmin) {
|
|
172
|
+
exchanges.push(` - name: ${n.exchange}
|
|
173
|
+
type: "direct"
|
|
174
|
+
createExchangeIfNotExists: true
|
|
175
|
+
options:
|
|
176
|
+
durable: true`);
|
|
177
|
+
}
|
|
178
|
+
exchanges.push(` - name: example.fanout
|
|
179
|
+
type: "fanout"
|
|
180
|
+
createExchangeIfNotExists: true
|
|
181
|
+
options:
|
|
182
|
+
durable: true
|
|
183
|
+
autoDelete: false
|
|
184
|
+
internal: false`);
|
|
185
|
+
|
|
186
|
+
const queues: string[] = [];
|
|
187
|
+
if (sel.acl) {
|
|
188
|
+
queues.push(` - name: ${n.aclQueue}
|
|
189
|
+
exchange: ${n.exchange}
|
|
190
|
+
routingKey: ${n.aclQueue}
|
|
191
|
+
createQueueIfNotExists: true
|
|
192
|
+
options:
|
|
193
|
+
durable: true`);
|
|
194
|
+
}
|
|
195
|
+
if (anyAdmin) {
|
|
196
|
+
queues.push(` - name: ${n.adminQueue}
|
|
197
|
+
exchange: ${n.exchange}
|
|
198
|
+
routingKey: ${n.adminQueue}
|
|
199
|
+
createQueueIfNotExists: true
|
|
200
|
+
options:
|
|
201
|
+
durable: true`);
|
|
202
|
+
}
|
|
203
|
+
queues.push(` - name: example.queue
|
|
204
|
+
exchange: example.fanout
|
|
205
|
+
routingKey: example.queue
|
|
206
|
+
createQueueIfNotExists: true
|
|
207
|
+
options:
|
|
208
|
+
durable: true
|
|
209
|
+
autoDelete: false
|
|
210
|
+
exclusive: false`);
|
|
211
|
+
|
|
212
|
+
const topics: string[] = [];
|
|
213
|
+
if (sel.acl) {
|
|
214
|
+
topics.push(` # Fixed topic name (decorator-bound in the lib): the ACL handlers bind to 'rlb-acl'.
|
|
215
|
+
- name: rlb-acl
|
|
216
|
+
mode: rpc
|
|
217
|
+
queue: ${n.aclQueue}
|
|
218
|
+
exchange: ${n.exchange}
|
|
219
|
+
routingKey: ${n.aclQueue}`);
|
|
220
|
+
}
|
|
221
|
+
if (anyAdmin) {
|
|
222
|
+
topics.push(` # Fixed topic name (decorator-bound): the gateway-admin handlers bind to 'rlb-gateway-admin'.
|
|
223
|
+
- name: rlb-gateway-admin
|
|
224
|
+
mode: rpc
|
|
225
|
+
queue: ${n.adminQueue}
|
|
226
|
+
exchange: ${n.exchange}
|
|
227
|
+
routingKey: ${n.adminQueue}
|
|
228
|
+
# Broadcast control topic: the gateway rebuilds its routes at runtime on a 'gw-reload'.
|
|
229
|
+
- name: ${n.controlTopic}
|
|
230
|
+
mode: broadcast
|
|
231
|
+
exchange: ${n.exchange}
|
|
232
|
+
routingKey: ${n.controlTopic}`);
|
|
233
|
+
}
|
|
234
|
+
topics.push(` - name: example.topic
|
|
235
|
+
exchange: example.fanout
|
|
236
|
+
routingKey: "example.topic"
|
|
237
|
+
mode: event`);
|
|
238
|
+
|
|
239
|
+
let yaml = `app:
|
|
58
240
|
port: 80
|
|
59
241
|
host: 0.0.0.0
|
|
60
242
|
environment: "development"
|
|
@@ -65,60 +247,386 @@ broker:
|
|
|
65
247
|
name: "rabbitmq"
|
|
66
248
|
uri: "<AMQP_URI>"
|
|
67
249
|
defaultSubscribeErrorBehavior: "ack"
|
|
68
|
-
defaultPublishErrorBehavior: "reject"
|
|
250
|
+
defaultPublishErrorBehavior: "reject"${routeDiscoveryPub}
|
|
69
251
|
connectionManagerOptions:
|
|
70
252
|
heartbeatIntervalInSeconds: 60
|
|
71
253
|
reconnectTimeInSeconds: 60
|
|
72
|
-
connectionOptions
|
|
73
|
-
clientProperties:
|
|
74
|
-
connection_name: "<APP_NAME>"
|
|
254
|
+
connectionOptions:${clientProps}
|
|
75
255
|
credentials:
|
|
76
256
|
mechanism: PLAIN
|
|
77
257
|
username: "<AMQP_USERNAME>"
|
|
78
258
|
password: "<AMQP_PASSWORD>"
|
|
79
259
|
exchanges:
|
|
80
|
-
|
|
81
|
-
type: "fanout"
|
|
82
|
-
createExchangeIfNotExists: true
|
|
83
|
-
options:
|
|
84
|
-
durable: true
|
|
85
|
-
autoDelete: false
|
|
86
|
-
internal: false
|
|
260
|
+
${exchanges.join('\n')}
|
|
87
261
|
queues:
|
|
88
|
-
|
|
89
|
-
createQueueIfNotExists: true
|
|
90
|
-
exchange: example.fanout
|
|
91
|
-
routingKey: example.queue
|
|
92
|
-
options:
|
|
93
|
-
durable: true
|
|
94
|
-
autoDelete: false
|
|
95
|
-
exclusive: false
|
|
262
|
+
${queues.join('\n')}
|
|
96
263
|
|
|
97
264
|
topics:
|
|
98
|
-
|
|
99
|
-
exchange: example.fanout
|
|
100
|
-
routingKey: "example.topic"
|
|
101
|
-
mode: event
|
|
265
|
+
${topics.join('\n')}
|
|
102
266
|
`;
|
|
103
267
|
|
|
104
|
-
|
|
105
|
-
|
|
268
|
+
if (sel.gatewayConfig) {
|
|
269
|
+
yaml += '\n' + buildGatewayBlock(sel);
|
|
270
|
+
}
|
|
271
|
+
return yaml;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildGatewayBlock(sel: Resolved): string {
|
|
275
|
+
const n = sel.names;
|
|
276
|
+
const anyAdmin = sel.admin || sel.routeReception;
|
|
277
|
+
const paths: string[] = [];
|
|
278
|
+
|
|
279
|
+
if (sel.acl) paths.push(ACL_PATHS);
|
|
280
|
+
if (sel.admin) paths.push(adminPaths(n.controlTopic));
|
|
281
|
+
paths.push(` - name: example-path
|
|
282
|
+
method: POST
|
|
283
|
+
dataSource: body
|
|
284
|
+
path: /example
|
|
285
|
+
topic: example.topic
|
|
286
|
+
action: example-action
|
|
287
|
+
mode: event`);
|
|
288
|
+
|
|
289
|
+
let block = `gateway:
|
|
106
290
|
events: []
|
|
107
291
|
ws:
|
|
108
292
|
heartbeatIntervalMs: 30000
|
|
109
293
|
# Auth is declared per-event (events[].auth / requireAuth / roles / scopeClaim).
|
|
110
294
|
paths:
|
|
111
|
-
|
|
295
|
+
${paths.join('\n')}`;
|
|
296
|
+
|
|
297
|
+
if (anyAdmin) {
|
|
298
|
+
block += `
|
|
299
|
+
# Load DB-managed routes at boot AND on every runtime reload (ordered static-before-param).
|
|
300
|
+
loadConfig:
|
|
301
|
+
paths:
|
|
302
|
+
topic: rlb-gateway-admin
|
|
303
|
+
action: gw-path-export
|
|
304
|
+
# Broadcast topic that triggers a runtime route rebuild (see topics: ${n.controlTopic}).
|
|
305
|
+
reloadTopic: ${n.controlTopic}
|
|
306
|
+
# Per-request metrics auto-emitted to the gateway-admin metrics handler.
|
|
307
|
+
metrics:
|
|
308
|
+
topic: rlb-gateway-admin
|
|
309
|
+
action: gw-metrics-track`;
|
|
310
|
+
}
|
|
311
|
+
return block + '\n';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const ACL_PATHS = ` # --- ACL management: actions (name is the key — PUT upserts, GET lists, DELETE by name) ---
|
|
315
|
+
- name: acl-action-list
|
|
316
|
+
method: GET
|
|
317
|
+
path: /acl/actions
|
|
318
|
+
dataSource: query
|
|
319
|
+
topic: rlb-acl
|
|
320
|
+
action: acl-action-list
|
|
321
|
+
mode: rpc
|
|
322
|
+
- name: acl-action-get
|
|
323
|
+
method: GET
|
|
324
|
+
path: /acl/actions/get
|
|
325
|
+
dataSource: query
|
|
326
|
+
topic: rlb-acl
|
|
327
|
+
action: acl-action-get
|
|
328
|
+
mode: rpc
|
|
329
|
+
- name: acl-action-upsert
|
|
330
|
+
method: PUT
|
|
331
|
+
path: /acl/actions
|
|
332
|
+
dataSource: body
|
|
333
|
+
topic: rlb-acl
|
|
334
|
+
action: acl-action-update
|
|
335
|
+
mode: rpc
|
|
336
|
+
- name: acl-action-delete
|
|
337
|
+
method: DELETE
|
|
338
|
+
path: /acl/actions
|
|
339
|
+
dataSource: body
|
|
340
|
+
topic: rlb-acl
|
|
341
|
+
action: acl-action-delete
|
|
342
|
+
mode: rpc
|
|
343
|
+
# --- ACL management: roles ---
|
|
344
|
+
- name: acl-role-list
|
|
345
|
+
method: GET
|
|
346
|
+
path: /acl/roles
|
|
347
|
+
dataSource: query
|
|
348
|
+
topic: rlb-acl
|
|
349
|
+
action: acl-role-list
|
|
350
|
+
mode: rpc
|
|
351
|
+
- name: acl-role-get
|
|
352
|
+
method: GET
|
|
353
|
+
path: /acl/roles/get
|
|
354
|
+
dataSource: query
|
|
355
|
+
topic: rlb-acl
|
|
356
|
+
action: acl-role-get
|
|
357
|
+
mode: rpc
|
|
358
|
+
- name: acl-role-upsert
|
|
359
|
+
method: PUT
|
|
360
|
+
path: /acl/roles
|
|
361
|
+
dataSource: body
|
|
362
|
+
topic: rlb-acl
|
|
363
|
+
action: acl-role-update
|
|
364
|
+
mode: rpc
|
|
365
|
+
- name: acl-role-delete
|
|
366
|
+
method: DELETE
|
|
367
|
+
path: /acl/roles
|
|
368
|
+
dataSource: body
|
|
369
|
+
topic: rlb-acl
|
|
370
|
+
action: acl-role-delete
|
|
371
|
+
mode: rpc
|
|
372
|
+
# --- ACL grants (per-user; resourceId/companyId optional) ---
|
|
373
|
+
- name: acl-grant
|
|
112
374
|
method: POST
|
|
375
|
+
path: /acl/grants
|
|
113
376
|
dataSource: body
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
377
|
+
topic: rlb-acl
|
|
378
|
+
action: acl-grant
|
|
379
|
+
mode: rpc
|
|
380
|
+
- name: acl-revoke
|
|
381
|
+
method: DELETE
|
|
382
|
+
path: /acl/grants
|
|
383
|
+
dataSource: body
|
|
384
|
+
topic: rlb-acl
|
|
385
|
+
action: acl-revoke
|
|
386
|
+
mode: rpc
|
|
387
|
+
# --- ACL checks (GET → 200 with true/false) ---
|
|
388
|
+
- name: acl-check-gtw
|
|
389
|
+
method: GET
|
|
390
|
+
path: /acl/check
|
|
391
|
+
dataSource: query
|
|
392
|
+
topic: rlb-acl
|
|
393
|
+
action: acl-can-user-do-gtw
|
|
394
|
+
mode: rpc
|
|
395
|
+
- name: acl-check-resource
|
|
396
|
+
method: GET
|
|
397
|
+
path: /acl/check-resource
|
|
398
|
+
dataSource: query
|
|
399
|
+
topic: rlb-acl
|
|
400
|
+
action: acl-can-user-do
|
|
401
|
+
mode: rpc
|
|
402
|
+
# Lists the caller's accessible resources. Add an 'auth: <provider>' line once you declare an auth-provider.
|
|
403
|
+
- name: acl-list-resources-by-user
|
|
404
|
+
method: GET
|
|
405
|
+
path: /acl/resources
|
|
406
|
+
dataSource: query
|
|
407
|
+
topic: rlb-acl
|
|
408
|
+
action: acl-list-resources-by-user
|
|
409
|
+
mode: rpc`;
|
|
410
|
+
|
|
411
|
+
function adminPaths(controlTopic: string): string {
|
|
412
|
+
return ` # --- gateway-admin: health + DB routes + auth-providers + metrics ---
|
|
413
|
+
- name: health
|
|
414
|
+
method: GET
|
|
415
|
+
path: /health
|
|
416
|
+
dataSource: query
|
|
417
|
+
topic: rlb-gateway-admin
|
|
418
|
+
action: gw-health
|
|
419
|
+
mode: rpc
|
|
420
|
+
- name: gw-path-create
|
|
421
|
+
method: POST
|
|
422
|
+
path: /admin/paths
|
|
423
|
+
dataSource: body
|
|
424
|
+
topic: rlb-gateway-admin
|
|
425
|
+
action: gw-path-create
|
|
426
|
+
mode: rpc
|
|
427
|
+
- name: gw-path-list
|
|
428
|
+
method: GET
|
|
429
|
+
path: /admin/paths
|
|
430
|
+
dataSource: query
|
|
431
|
+
topic: rlb-gateway-admin
|
|
432
|
+
action: gw-path-list
|
|
433
|
+
mode: rpc
|
|
434
|
+
- name: gw-path-export
|
|
435
|
+
method: GET
|
|
436
|
+
path: /admin/paths/export
|
|
437
|
+
dataSource: query
|
|
438
|
+
topic: rlb-gateway-admin
|
|
439
|
+
action: gw-path-export
|
|
440
|
+
mode: rpc
|
|
441
|
+
- name: gw-path-update
|
|
442
|
+
method: PUT
|
|
443
|
+
path: /admin/paths
|
|
444
|
+
dataSource: body
|
|
445
|
+
topic: rlb-gateway-admin
|
|
446
|
+
action: gw-path-update
|
|
447
|
+
mode: rpc
|
|
448
|
+
- name: gw-path-get
|
|
449
|
+
method: GET
|
|
450
|
+
path: /admin/paths/get
|
|
451
|
+
dataSource: query
|
|
452
|
+
topic: rlb-gateway-admin
|
|
453
|
+
action: gw-path-get
|
|
454
|
+
mode: rpc
|
|
455
|
+
- name: gw-path-delete
|
|
456
|
+
method: DELETE
|
|
457
|
+
path: /admin/paths
|
|
458
|
+
dataSource: body
|
|
459
|
+
topic: rlb-gateway-admin
|
|
460
|
+
action: gw-path-delete
|
|
461
|
+
mode: rpc
|
|
462
|
+
- name: gw-auth-list
|
|
463
|
+
method: GET
|
|
464
|
+
path: /admin/auth
|
|
465
|
+
dataSource: query
|
|
466
|
+
topic: rlb-gateway-admin
|
|
467
|
+
action: gw-auth-list
|
|
468
|
+
mode: rpc
|
|
469
|
+
- name: gw-auth-upsert
|
|
470
|
+
method: PUT
|
|
471
|
+
path: /admin/auth
|
|
472
|
+
dataSource: body
|
|
473
|
+
topic: rlb-gateway-admin
|
|
474
|
+
action: gw-auth-update
|
|
475
|
+
mode: rpc
|
|
476
|
+
- name: gw-auth-get
|
|
477
|
+
method: GET
|
|
478
|
+
path: /admin/auth/get
|
|
479
|
+
dataSource: query
|
|
480
|
+
topic: rlb-gateway-admin
|
|
481
|
+
action: gw-auth-get
|
|
482
|
+
mode: rpc
|
|
483
|
+
- name: gw-auth-delete
|
|
484
|
+
method: DELETE
|
|
485
|
+
path: /admin/auth
|
|
486
|
+
dataSource: body
|
|
487
|
+
topic: rlb-gateway-admin
|
|
488
|
+
action: gw-auth-delete
|
|
489
|
+
mode: rpc
|
|
490
|
+
- name: gw-metrics-get
|
|
491
|
+
method: GET
|
|
492
|
+
path: /admin/metrics
|
|
493
|
+
dataSource: query
|
|
494
|
+
topic: rlb-gateway-admin
|
|
495
|
+
action: gw-metrics-get
|
|
496
|
+
mode: rpc
|
|
497
|
+
- name: gw-metrics-series
|
|
498
|
+
method: GET
|
|
499
|
+
path: /admin/metrics/series
|
|
500
|
+
dataSource: query
|
|
501
|
+
topic: rlb-gateway-admin
|
|
502
|
+
action: gw-metrics-series
|
|
503
|
+
mode: rpc
|
|
504
|
+
- name: gw-metrics-points
|
|
505
|
+
method: GET
|
|
506
|
+
path: /admin/metrics/points
|
|
507
|
+
dataSource: query
|
|
508
|
+
topic: rlb-gateway-admin
|
|
509
|
+
action: gw-metrics-points
|
|
510
|
+
mode: rpc
|
|
511
|
+
- name: gw-metrics-track
|
|
512
|
+
method: POST
|
|
513
|
+
path: /admin/metrics/track
|
|
514
|
+
dataSource: body
|
|
515
|
+
topic: rlb-gateway-admin
|
|
516
|
+
action: gw-metrics-track
|
|
117
517
|
mode: event
|
|
118
|
-
|
|
518
|
+
- name: gw-reload
|
|
519
|
+
method: POST
|
|
520
|
+
path: /admin/reload
|
|
521
|
+
dataSource: body
|
|
522
|
+
topic: ${controlTopic}
|
|
523
|
+
action: gw-reload
|
|
524
|
+
mode: event`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// AppModule wiring (import statements + @Module imports entries)
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
function buildImportStatements(sel: Resolved): string {
|
|
532
|
+
const libSymbols = ['AppConfig', 'BrokerModule', 'BrokerTopic', 'RabbitMQConfig'];
|
|
533
|
+
if (sel.gatewayConfig) libSymbols.push('GatewayConfig', 'HandlerAuthConfig', 'ProxyModule');
|
|
534
|
+
if (sel.acl) libSymbols.push('AclActionRepository', 'AclGrantRepository', 'AclModule', 'AclRoleRepository', 'AclService', 'RLB_ACL_CACHE_STORE', 'RLB_GTW_ACL_ROLE_SERVICE');
|
|
535
|
+
if (sel.admin || sel.routeReception) libSymbols.push('AuthProviderRepository', 'GatewayAdminModule', 'HttpMetricRepository', 'HttpPathRepository', 'RouteSyncLogRepository');
|
|
536
|
+
const lib = `import { ${[...new Set(libSymbols)].sort().join(', ')} } from '@open-rlb/nestjs-amqp';`;
|
|
537
|
+
|
|
538
|
+
const lines = [lib, `import { ConfigModule, ConfigService } from '@nestjs/config';`, `import yamlConfig from './config/config.loader';`];
|
|
539
|
+
if (sel.gatewayConfig) lines.push(`import { HttpModule } from '@nestjs/axios';`);
|
|
540
|
+
if (sel.acl) {
|
|
541
|
+
lines.push(`import { InMemoryAclActionRepository, InMemoryAclGrantRepository, InMemoryAclRoleRepository } from './modules/database/repository/acl.repository';`);
|
|
542
|
+
lines.push(`import { InMemoryAclStore } from './cache/in-memory-acl-store';`);
|
|
543
|
+
}
|
|
544
|
+
if (sel.admin || sel.routeReception) {
|
|
545
|
+
lines.push(`import { InMemoryAuthProviderRepository, InMemoryHttpMetricRepository, InMemoryHttpPathRepository } from './modules/database/repository/gateway.repository';`);
|
|
546
|
+
lines.push(`import { InMemoryRouteSyncLogRepository } from './modules/database/repository/route-sync.repository';`);
|
|
547
|
+
}
|
|
548
|
+
return lines.join('\n');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function brokerForRootAsync(): string {
|
|
552
|
+
return `BrokerModule.forRootAsync({
|
|
553
|
+
imports: [ConfigModule],
|
|
554
|
+
inject: [ConfigService],
|
|
555
|
+
useFactory: async (configService: ConfigService) => ({
|
|
556
|
+
options: configService.get<RabbitMQConfig>('broker')!,
|
|
557
|
+
topics: configService.get<BrokerTopic[]>('topics')!,
|
|
558
|
+
appOptions: configService.get<AppConfig>('app'),
|
|
559
|
+
})
|
|
560
|
+
})`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function proxyForRootAsync(sel: Resolved): string {
|
|
564
|
+
const providers = sel.acl
|
|
565
|
+
? `[
|
|
566
|
+
// Role-gated paths resolve the caller's roles via AclService (in-process, no broker hop).
|
|
567
|
+
{ provide: RLB_GTW_ACL_ROLE_SERVICE, useExisting: AclService },
|
|
568
|
+
]`
|
|
569
|
+
: `[]`;
|
|
570
|
+
return `ProxyModule.forRootAsync({
|
|
571
|
+
imports: [ConfigModule],
|
|
572
|
+
inject: [ConfigService],
|
|
573
|
+
useFactory: (configService: ConfigService) => ({
|
|
574
|
+
authOptions: configService.get<HandlerAuthConfig[]>('auth-providers'),
|
|
575
|
+
gatewayOptions: configService.get<GatewayConfig>('gateway'),
|
|
576
|
+
}),
|
|
577
|
+
providers: ${providers},
|
|
578
|
+
})`;
|
|
579
|
+
}
|
|
119
580
|
|
|
120
|
-
function
|
|
121
|
-
return
|
|
581
|
+
function aclForRoot(): string {
|
|
582
|
+
return `AclModule.forRoot(
|
|
583
|
+
[
|
|
584
|
+
InMemoryAclActionRepository,
|
|
585
|
+
{ provide: AclActionRepository, useExisting: InMemoryAclActionRepository },
|
|
586
|
+
InMemoryAclRoleRepository,
|
|
587
|
+
{ provide: AclRoleRepository, useExisting: InMemoryAclRoleRepository },
|
|
588
|
+
InMemoryAclGrantRepository,
|
|
589
|
+
{ provide: AclGrantRepository, useExisting: InMemoryAclGrantRepository },
|
|
590
|
+
InMemoryAclStore,
|
|
591
|
+
{ provide: RLB_ACL_CACHE_STORE, useExisting: InMemoryAclStore },
|
|
592
|
+
],
|
|
593
|
+
{ cache: { ramTtlMs: 30000, l2TtlSec: 600 } },
|
|
594
|
+
)`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function gatewayAdminForRoot(sel: Resolved): string {
|
|
598
|
+
const options = sel.routeReception
|
|
599
|
+
? `,
|
|
600
|
+
{
|
|
601
|
+
// Consumer-side route-discovery — names MUST match the publishers' broker.routeDiscovery.
|
|
602
|
+
routeDiscovery: { exchange: '${sel.names.routeExchange}', queue: '${sel.names.routeQueue}' },
|
|
603
|
+
}`
|
|
604
|
+
: '';
|
|
605
|
+
return `GatewayAdminModule.forRoot(
|
|
606
|
+
[
|
|
607
|
+
InMemoryHttpPathRepository,
|
|
608
|
+
{ provide: HttpPathRepository, useExisting: InMemoryHttpPathRepository },
|
|
609
|
+
InMemoryAuthProviderRepository,
|
|
610
|
+
{ provide: AuthProviderRepository, useExisting: InMemoryAuthProviderRepository },
|
|
611
|
+
InMemoryHttpMetricRepository,
|
|
612
|
+
{ provide: HttpMetricRepository, useExisting: InMemoryHttpMetricRepository },
|
|
613
|
+
InMemoryRouteSyncLogRepository,
|
|
614
|
+
{ provide: RouteSyncLogRepository, useExisting: InMemoryRouteSyncLogRepository },
|
|
615
|
+
]${options},
|
|
616
|
+
)`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function buildModuleEntries(sel: Resolved): string[] {
|
|
620
|
+
const entries: string[] = [];
|
|
621
|
+
entries.push(`ConfigModule.forRoot({ isGlobal: true, load: [yamlConfig] })`);
|
|
622
|
+
entries.push(brokerForRootAsync());
|
|
623
|
+
if (sel.gatewayConfig) {
|
|
624
|
+
entries.push('HttpModule');
|
|
625
|
+
entries.push(proxyForRootAsync(sel));
|
|
626
|
+
}
|
|
627
|
+
if (sel.acl) entries.push(aclForRoot());
|
|
628
|
+
if (sel.admin || sel.routeReception) entries.push(gatewayAdminForRoot(sel));
|
|
629
|
+
return entries;
|
|
122
630
|
}
|
|
123
631
|
|
|
124
632
|
// ---------------------------------------------------------------------------
|
|
@@ -126,25 +634,30 @@ function configYaml(gateway: boolean): string {
|
|
|
126
634
|
// ---------------------------------------------------------------------------
|
|
127
635
|
|
|
128
636
|
export function main(options: InitOptions): Rule {
|
|
637
|
+
const project = (options.project || 'my-service').toString();
|
|
129
638
|
options = transform(options);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
639
|
+
return async (tree: Tree, context: SchematicContext) => {
|
|
640
|
+
const sel = await resolveSelections({ ...options, project } as InitOptions, context);
|
|
641
|
+
const anyRepo = sel.acl || sel.admin || sel.routeReception;
|
|
133
642
|
return branchAndMerge(
|
|
134
643
|
chain([
|
|
135
644
|
mergeSourceRoot(options),
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
645
|
+
addModulesToAppModule(sel),
|
|
646
|
+
createConfigLoader(),
|
|
647
|
+
updateConfigYaml(sel),
|
|
648
|
+
sel.gatewayConfig ? configureMainForGateway() : noop(),
|
|
649
|
+
anyRepo ? copyAsset('db-core') : noop(),
|
|
650
|
+
sel.acl ? copyAsset('acl') : noop(),
|
|
651
|
+
(sel.admin || sel.routeReception) ? copyAsset('gateway-admin') : noop(),
|
|
652
|
+
sel.skills ? copySkills() : noop(),
|
|
653
|
+
updatePackageJson(sel),
|
|
141
654
|
]),
|
|
142
|
-
)
|
|
655
|
+
);
|
|
143
656
|
};
|
|
144
657
|
}
|
|
145
658
|
|
|
146
659
|
// ---------------------------------------------------------------------------
|
|
147
|
-
// Helpers
|
|
660
|
+
// Helpers
|
|
148
661
|
// ---------------------------------------------------------------------------
|
|
149
662
|
|
|
150
663
|
function transform(source: InitOptions): InitOptions {
|
|
@@ -155,129 +668,101 @@ function transform(source: InitOptions): InitOptions {
|
|
|
155
668
|
const location: Location = new NameParser().parse({ ...target, name: 'init' });
|
|
156
669
|
target.name = '';
|
|
157
670
|
target.path = normalizeToKebabOrSnakeCase(location.path);
|
|
158
|
-
target.specFileSuffix = normalizeToKebabOrSnakeCase(
|
|
159
|
-
source.specFileSuffix || 'spec',
|
|
160
|
-
);
|
|
671
|
+
target.specFileSuffix = normalizeToKebabOrSnakeCase(source.specFileSuffix || 'spec');
|
|
161
672
|
return target;
|
|
162
673
|
}
|
|
163
674
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
675
|
+
/** Copy a static asset tree under ./files/<name> to the project root (no templating). */
|
|
676
|
+
function copyAsset(name: string): Rule {
|
|
677
|
+
return mergeWith(apply(url(`./files/${name}`), [move(normalize('.'))]), MergeStrategy.Overwrite);
|
|
678
|
+
}
|
|
167
679
|
|
|
680
|
+
/** Copy the Claude skills tree to .claude/skills. */
|
|
168
681
|
function copySkills(): Rule {
|
|
169
|
-
|
|
170
|
-
return mergeWith(
|
|
171
|
-
apply(url('./files/skills'), [move(normalize('.claude/skills'))]),
|
|
172
|
-
MergeStrategy.Overwrite,
|
|
173
|
-
);
|
|
682
|
+
return mergeWith(apply(url('./files/skills'), [move(normalize('.claude/skills'))]), MergeStrategy.Overwrite);
|
|
174
683
|
}
|
|
175
684
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
685
|
+
/** Create src/config/config.loader.ts (js-yaml loader) if it doesn't exist yet. */
|
|
686
|
+
function createConfigLoader(): Rule {
|
|
687
|
+
return (tree: Tree) => {
|
|
688
|
+
const path = 'src/config/config.loader.ts';
|
|
689
|
+
if (tree.exists(path)) return tree;
|
|
690
|
+
tree.create(path, `import { readFileSync } from 'fs';
|
|
691
|
+
import * as yaml from 'js-yaml';
|
|
692
|
+
import { join } from 'path';
|
|
693
|
+
|
|
694
|
+
const YAML_CONFIG_FILENAME = 'config/config.yaml';
|
|
695
|
+
|
|
696
|
+
export default () =>
|
|
697
|
+
yaml.load(readFileSync(join(process.cwd(), YAML_CONFIG_FILENAME), 'utf8')) as Record<string, any>;
|
|
698
|
+
`);
|
|
699
|
+
return tree;
|
|
700
|
+
};
|
|
701
|
+
}
|
|
179
702
|
|
|
180
|
-
function updateConfigYaml(
|
|
703
|
+
function updateConfigYaml(sel: Resolved): Rule {
|
|
181
704
|
return (tree: Tree) => {
|
|
182
705
|
const CONFIG_PATH = 'config/config.yaml';
|
|
183
|
-
const block =
|
|
706
|
+
const block = buildConfigYaml(sel);
|
|
184
707
|
|
|
185
708
|
if (!tree.exists(CONFIG_PATH)) {
|
|
186
|
-
|
|
187
|
-
tree.create(CONFIG_PATH, block.trimStart());
|
|
709
|
+
tree.create(CONFIG_PATH, block);
|
|
188
710
|
return tree;
|
|
189
711
|
}
|
|
190
712
|
|
|
191
|
-
//
|
|
713
|
+
// Existing file: append only the still-missing top-level sections.
|
|
192
714
|
const existing = tree.read(CONFIG_PATH)!.toString('utf-8');
|
|
193
|
-
const SECTION_KEYS = ['app:', 'auth-providers:', 'broker:', 'topics:', ...(
|
|
715
|
+
const SECTION_KEYS = ['app:', 'auth-providers:', 'broker:', 'topics:', ...(sel.gatewayConfig ? ['gateway:'] : [])] as const;
|
|
194
716
|
|
|
195
717
|
let toAppend = '';
|
|
196
718
|
for (const key of SECTION_KEYS) {
|
|
197
719
|
if (!existing.includes(key)) {
|
|
198
720
|
const section = extractYamlSection(block, key);
|
|
199
|
-
if (section)
|
|
200
|
-
toAppend += '\n' + section;
|
|
201
|
-
}
|
|
721
|
+
if (section) toAppend += '\n' + section + '\n';
|
|
202
722
|
}
|
|
203
723
|
}
|
|
204
|
-
|
|
205
724
|
if (toAppend.length > 0) {
|
|
206
725
|
tree.overwrite(CONFIG_PATH, existing.trimEnd() + '\n' + toAppend);
|
|
207
726
|
}
|
|
208
|
-
|
|
209
727
|
return tree;
|
|
210
728
|
};
|
|
211
729
|
}
|
|
212
730
|
|
|
213
|
-
/**
|
|
214
|
-
* Estrae dal template YAML il blocco che inizia con `sectionKey`
|
|
215
|
-
* e termina prima del prossimo blocco di primo livello (chiave senza indentazione).
|
|
216
|
-
*/
|
|
731
|
+
/** Extracts the YAML block starting at `sectionKey` up to the next top-level key. */
|
|
217
732
|
function extractYamlSection(yaml: string, sectionKey: string): string {
|
|
218
733
|
const lines = yaml.split('\n');
|
|
219
|
-
const startIdx = lines.findIndex(l => l.startsWith(sectionKey));
|
|
734
|
+
const startIdx = lines.findIndex((l) => l.startsWith(sectionKey));
|
|
220
735
|
if (startIdx === -1) return '';
|
|
221
|
-
|
|
222
|
-
const endIdx = lines.findIndex(
|
|
223
|
-
(l, i) => i > startIdx && l.length > 0 && !l.startsWith(' ') && !l.startsWith('#'),
|
|
224
|
-
);
|
|
225
|
-
|
|
736
|
+
const endIdx = lines.findIndex((l, i) => i > startIdx && l.length > 0 && !l.startsWith(' ') && !l.startsWith('#'));
|
|
226
737
|
const sectionLines = endIdx === -1 ? lines.slice(startIdx) : lines.slice(startIdx, endIdx);
|
|
227
738
|
return sectionLines.join('\n').trimEnd();
|
|
228
739
|
}
|
|
229
740
|
|
|
230
|
-
|
|
231
|
-
// Rule: aggiunge BrokerModule.forRootAsync(...) (+ gateway) ad app.module.ts
|
|
232
|
-
// ---------------------------------------------------------------------------
|
|
233
|
-
|
|
234
|
-
function addBrokerModuleToAppModule(gateway: boolean): Rule {
|
|
741
|
+
function addModulesToAppModule(sel: Resolved): Rule {
|
|
235
742
|
return (tree: Tree) => {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
'/src/app.module.ts',
|
|
239
|
-
'/app/app.module.ts',
|
|
240
|
-
'src/app.module.ts',
|
|
241
|
-
'app/app.module.ts',
|
|
242
|
-
];
|
|
243
|
-
|
|
244
|
-
let modulePath: string | undefined = candidatePaths.find(p => tree.exists(p));
|
|
245
|
-
|
|
743
|
+
const candidatePaths = ['/src/app.module.ts', '/app/app.module.ts', 'src/app.module.ts', 'app/app.module.ts'];
|
|
744
|
+
let modulePath: string | undefined = candidatePaths.find((p) => tree.exists(p)) || findFileInTree(tree, 'app.module.ts');
|
|
246
745
|
if (!modulePath) {
|
|
247
|
-
|
|
248
|
-
modulePath = findFileInTree(tree, 'app.module.ts');
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (!modulePath) {
|
|
252
|
-
console.warn('[nest-add] app.module.ts non trovato: BrokerModule non aggiunto.');
|
|
746
|
+
console.warn('[nest-add] app.module.ts not found: AppModule wiring skipped.');
|
|
253
747
|
return tree;
|
|
254
748
|
}
|
|
255
749
|
|
|
256
|
-
const
|
|
257
|
-
if (!
|
|
258
|
-
|
|
259
|
-
}
|
|
750
|
+
const raw = tree.read(modulePath);
|
|
751
|
+
if (!raw) return tree;
|
|
752
|
+
let content = raw.toString('utf-8');
|
|
260
753
|
|
|
261
|
-
|
|
754
|
+
// Sentinel: BrokerModule.forRootAsync means "already wired" → idempotent no-op.
|
|
755
|
+
if (content.includes('BrokerModule.forRootAsync')) return tree;
|
|
262
756
|
|
|
263
|
-
// ---- 1. Aggiunge l'import statement se non già presente ----------------
|
|
264
757
|
if (!content.includes('@open-rlb/nestjs-amqp')) {
|
|
265
|
-
const
|
|
266
|
-
content =
|
|
267
|
-
content.slice(0, importInsertPos) +
|
|
268
|
-
'\n' + brokerImportLine(gateway) +
|
|
269
|
-
content.slice(importInsertPos);
|
|
758
|
+
const pos = findLastImportEndIndex(content);
|
|
759
|
+
content = content.slice(0, pos) + '\n' + buildImportStatements(sel) + content.slice(pos);
|
|
270
760
|
}
|
|
271
761
|
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (gateway) {
|
|
277
|
-
content = insertIntoImportsArray(content, proxyForRootAsync());
|
|
278
|
-
content = insertIntoImportsArray(content, 'HttpModule');
|
|
279
|
-
}
|
|
280
|
-
content = insertIntoImportsArray(content, brokerForRootAsync());
|
|
762
|
+
// Insert the module entries (reverse so the final order matches buildModuleEntries()).
|
|
763
|
+
const entries = buildModuleEntries(sel);
|
|
764
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
765
|
+
content = insertIntoImportsArray(content, entries[i]);
|
|
281
766
|
}
|
|
282
767
|
|
|
283
768
|
tree.overwrite(modulePath, content);
|
|
@@ -285,36 +770,28 @@ function addBrokerModuleToAppModule(gateway: boolean): Rule {
|
|
|
285
770
|
};
|
|
286
771
|
}
|
|
287
772
|
|
|
288
|
-
// ---------------------------------------------------------------------------
|
|
289
|
-
// Rule: abilita rawBody + WsAdapter in main.ts (solo gateway)
|
|
290
|
-
// ---------------------------------------------------------------------------
|
|
291
|
-
|
|
292
773
|
function configureMainForGateway(): Rule {
|
|
293
774
|
return (tree: Tree) => {
|
|
294
775
|
const candidatePaths = ['/src/main.ts', '/app/main.ts', 'src/main.ts', 'app/main.ts'];
|
|
295
|
-
const mainPath = candidatePaths.find(p => tree.exists(p)) || findFileInTree(tree, 'main.ts');
|
|
776
|
+
const mainPath = candidatePaths.find((p) => tree.exists(p)) || findFileInTree(tree, 'main.ts');
|
|
296
777
|
if (!mainPath) {
|
|
297
|
-
console.warn('[nest-add] main.ts
|
|
778
|
+
console.warn('[nest-add] main.ts not found: enable rawBody + WsAdapter manually.');
|
|
298
779
|
return tree;
|
|
299
780
|
}
|
|
300
781
|
let content = tree.read(mainPath)!.toString('utf-8');
|
|
301
|
-
if (content.includes('WsAdapter'))
|
|
302
|
-
return tree; // già configurato
|
|
303
|
-
}
|
|
782
|
+
if (content.includes('WsAdapter')) return tree;
|
|
304
783
|
|
|
305
|
-
// import WsAdapter dopo l'ultimo import
|
|
306
784
|
const pos = findLastImportEndIndex(content);
|
|
307
785
|
content = content.slice(0, pos) + "\nimport { WsAdapter } from '@nestjs/platform-ws';" + content.slice(pos);
|
|
308
786
|
|
|
309
|
-
// abilita rawBody e registra il WS adapter subito dopo NestFactory.create(...)
|
|
310
787
|
const m = content.match(/const\s+(\w+)\s*=\s*await\s+NestFactory\.create\(\s*([A-Za-z0-9_]+)\s*(,\s*\{[^}]*\})?\s*\)\s*;?/);
|
|
311
788
|
if (m) {
|
|
312
789
|
const appVar = m[1];
|
|
313
790
|
const moduleArg = m[2];
|
|
314
|
-
const replacement = `const ${appVar} = await NestFactory.create(${moduleArg}, { rawBody: true });\n ${appVar}.useWebSocketAdapter(new WsAdapter(${appVar}));`;
|
|
791
|
+
const replacement = `const ${appVar} = await NestFactory.create(${moduleArg}, { rawBody: true });\n ${appVar}.useWebSocketAdapter(new WsAdapter(${appVar}));\n ${appVar}.enableShutdownHooks();`;
|
|
315
792
|
content = content.replace(m[0], replacement);
|
|
316
793
|
} else {
|
|
317
|
-
console.warn('[nest-add] NestFactory.create()
|
|
794
|
+
console.warn('[nest-add] NestFactory.create() not found in main.ts: add { rawBody: true } + useWebSocketAdapter manually.');
|
|
318
795
|
}
|
|
319
796
|
|
|
320
797
|
tree.overwrite(mainPath, content);
|
|
@@ -322,64 +799,30 @@ function configureMainForGateway(): Rule {
|
|
|
322
799
|
};
|
|
323
800
|
}
|
|
324
801
|
|
|
325
|
-
/**
|
|
326
|
-
* Inserisce `moduleEntry` all'interno del primo array `imports: [...]`
|
|
327
|
-
* trovato nel decoratore @Module di NestJS.
|
|
328
|
-
*
|
|
329
|
-
* Gestisce sia array su singola riga sia array multi-riga.
|
|
330
|
-
*/
|
|
802
|
+
/** Inserts `moduleEntry` at the head of the first `imports: [...]` array in the @Module decorator. */
|
|
331
803
|
function insertIntoImportsArray(source: string, moduleEntry: string): string {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const match = importsArrayRegex.exec(source);
|
|
335
|
-
if (!match) {
|
|
336
|
-
return source;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Posizione del "[" che apre l'array
|
|
804
|
+
const match = /imports\s*:\s*\[/.exec(source);
|
|
805
|
+
if (!match) return source;
|
|
340
806
|
const openBracketPos = source.indexOf('[', match.index);
|
|
341
807
|
|
|
342
|
-
// Trova il corrispondente "]" tenendo conto di bracket annidati
|
|
343
808
|
let depth = 0;
|
|
344
809
|
let closeBracketPos = -1;
|
|
345
810
|
for (let i = openBracketPos; i < source.length; i++) {
|
|
346
811
|
if (source[i] === '[') depth++;
|
|
347
812
|
else if (source[i] === ']') {
|
|
348
813
|
depth--;
|
|
349
|
-
if (depth === 0) {
|
|
350
|
-
closeBracketPos = i;
|
|
351
|
-
break;
|
|
352
|
-
}
|
|
814
|
+
if (depth === 0) { closeBracketPos = i; break; }
|
|
353
815
|
}
|
|
354
816
|
}
|
|
817
|
+
if (closeBracketPos === -1) return source;
|
|
355
818
|
|
|
356
|
-
if (closeBracketPos === -1) {
|
|
357
|
-
return source; // array non chiuso correttamente
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Determina il contenuto corrente dell'array (senza le parentesi quadre)
|
|
361
819
|
const arrayContent = source.slice(openBracketPos + 1, closeBracketPos).trim();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
newArrayContent = `\n ${moduleEntry},\n `;
|
|
367
|
-
} else {
|
|
368
|
-
// Array con contenuto → aggiunge in testa (prima entry), con virgola finale
|
|
369
|
-
newArrayContent = `\n ${moduleEntry},\n ${arrayContent}\n `;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return (
|
|
373
|
-
source.slice(0, openBracketPos + 1) +
|
|
374
|
-
newArrayContent +
|
|
375
|
-
source.slice(closeBracketPos)
|
|
376
|
-
);
|
|
820
|
+
const newArrayContent = arrayContent.length === 0
|
|
821
|
+
? `\n ${moduleEntry},\n `
|
|
822
|
+
: `\n ${moduleEntry},\n ${arrayContent}\n `;
|
|
823
|
+
return source.slice(0, openBracketPos + 1) + newArrayContent + source.slice(closeBracketPos);
|
|
377
824
|
}
|
|
378
825
|
|
|
379
|
-
/**
|
|
380
|
-
* Restituisce la posizione di fine dell'ultimo statement `import` nel file,
|
|
381
|
-
* utile per inserire un nuovo import subito dopo l'ultimo esistente.
|
|
382
|
-
*/
|
|
383
826
|
function findLastImportEndIndex(source: string): number {
|
|
384
827
|
const importRegex = /^import\s+.+from\s+['"][^'"]+['"];?\s*$/gm;
|
|
385
828
|
let lastEnd = 0;
|
|
@@ -390,55 +833,44 @@ function findLastImportEndIndex(source: string): number {
|
|
|
390
833
|
return lastEnd;
|
|
391
834
|
}
|
|
392
835
|
|
|
393
|
-
/**
|
|
394
|
-
* Ricerca ricorsiva di un file per nome all'interno dell'albero Schematics.
|
|
395
|
-
*/
|
|
396
836
|
function findFileInTree(tree: Tree, fileName: string): string | undefined {
|
|
397
837
|
let found: string | undefined;
|
|
398
|
-
tree.visit(path => {
|
|
399
|
-
if (!found && path.endsWith(`/${fileName}`))
|
|
400
|
-
found = path;
|
|
401
|
-
}
|
|
838
|
+
tree.visit((path) => {
|
|
839
|
+
if (!found && path.endsWith(`/${fileName}`)) found = path;
|
|
402
840
|
});
|
|
403
841
|
return found;
|
|
404
842
|
}
|
|
405
843
|
|
|
406
844
|
// ---------------------------------------------------------------------------
|
|
407
|
-
//
|
|
845
|
+
// package.json: add the runtime deps the generated code imports.
|
|
408
846
|
// ---------------------------------------------------------------------------
|
|
409
847
|
|
|
410
|
-
function updatePackageJson(
|
|
848
|
+
function updatePackageJson(sel: Resolved): Rule {
|
|
411
849
|
return (host: Tree) => {
|
|
412
|
-
if (!host.exists('package.json'))
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
850
|
+
if (!host.exists('package.json')) return host;
|
|
851
|
+
return updateJsonFile(host, 'package.json', (packageJson: Record<string, any>) => {
|
|
852
|
+
packageJson.dependencies = packageJson.dependencies || {};
|
|
853
|
+
const add = (name: string, version: string) => {
|
|
854
|
+
if (!packageJson.dependencies[name]) packageJson.dependencies[name] = version;
|
|
855
|
+
};
|
|
856
|
+
add('@nestjs/config', '^4.0.4');
|
|
857
|
+
add('js-yaml', '^4.1.0');
|
|
858
|
+
if (sel.gatewayConfig) {
|
|
859
|
+
add('@nestjs/axios', '^4.0.1');
|
|
860
|
+
add('@nestjs/platform-ws', '^11.0.1');
|
|
861
|
+
add('@nestjs/websockets', '^11.0.1');
|
|
862
|
+
add('ws', '^8.21.0');
|
|
863
|
+
}
|
|
864
|
+
});
|
|
422
865
|
};
|
|
423
866
|
}
|
|
424
867
|
|
|
425
|
-
function updateJsonFile<T>(
|
|
426
|
-
host: Tree,
|
|
427
|
-
path: string,
|
|
428
|
-
callback: UpdateJsonFn<T>,
|
|
429
|
-
): Tree {
|
|
868
|
+
function updateJsonFile<T>(host: Tree, path: string, callback: UpdateJsonFn<T>): Tree {
|
|
430
869
|
const source = host.read(path);
|
|
431
870
|
if (source) {
|
|
432
|
-
const
|
|
433
|
-
const json = parse(sourceText);
|
|
871
|
+
const json = parse(source.toString('utf-8'));
|
|
434
872
|
callback(json as unknown as T);
|
|
435
873
|
host.overwrite(path, JSON.stringify(json, null, 2));
|
|
436
874
|
}
|
|
437
875
|
return host;
|
|
438
876
|
}
|
|
439
|
-
|
|
440
|
-
function updateNpmScripts(scripts: Record<string, any>, _options: InitOptions) {
|
|
441
|
-
if (!scripts) {
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
}
|