eva4j 1.0.13 → 1.0.15
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/AGENTS.md +314 -10
- package/COMMAND_EVALUATION.md +15 -16
- package/DOMAIN_YAML_GUIDE.md +576 -10
- package/FUTURE_FEATURES.md +1627 -1168
- package/README.md +318 -13
- package/bin/eva4j.js +34 -0
- package/config/defaults.json +1 -0
- package/design-system.md +797 -0
- package/docs/commands/EVALUATE_SYSTEM.md +994 -0
- package/docs/commands/GENERATE_ENTITIES.md +795 -6
- package/docs/commands/INDEX.md +10 -1
- package/examples/domain-endpoints-relations.yaml +353 -0
- package/examples/domain-endpoints-versioned.yaml +144 -0
- package/examples/domain-endpoints.yaml +135 -0
- package/examples/domain-events.yaml +166 -20
- package/examples/domain-listeners.yaml +212 -0
- package/examples/domain-one-to-many.yaml +1 -0
- package/examples/domain-one-to-one.yaml +1 -0
- package/examples/domain-ports.yaml +414 -0
- package/examples/domain-soft-delete.yaml +47 -44
- package/examples/system/notification.yaml +147 -0
- package/examples/system/product.yaml +185 -0
- package/examples/system/system.yaml +112 -0
- package/examples/system-report.html +971 -0
- package/examples/system.yaml +332 -0
- package/package.json +2 -1
- package/src/commands/build.js +714 -0
- package/src/commands/create.js +7 -3
- package/src/commands/detach.js +1 -0
- package/src/commands/evaluate-system.js +610 -0
- package/src/commands/generate-entities.js +1331 -49
- package/src/commands/generate-http-exchange.js +2 -0
- package/src/commands/generate-kafka-event.js +98 -11
- package/src/generators/base-generator.js +8 -1
- package/src/generators/postman-generator.js +188 -0
- package/src/generators/shared-generator.js +10 -0
- package/src/utils/config-manager.js +54 -0
- package/src/utils/context-builder.js +1 -0
- package/src/utils/domain-diagram.js +192 -0
- package/src/utils/domain-validator.js +970 -0
- package/src/utils/fake-data.js +376 -0
- package/src/utils/naming.js +3 -2
- package/src/utils/system-validator.js +434 -0
- package/src/utils/yaml-to-entity.js +302 -8
- package/templates/aggregate/AggregateMapper.java.ejs +3 -2
- package/templates/aggregate/AggregateRepository.java.ejs +8 -2
- package/templates/aggregate/AggregateRepositoryImpl.java.ejs +13 -3
- package/templates/aggregate/AggregateRoot.java.ejs +60 -2
- package/templates/aggregate/DomainEventHandler.java.ejs +27 -20
- package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
- package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
- package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
- package/templates/aggregate/JpaRepository.java.ejs +5 -0
- package/templates/base/gradle/build.gradle.ejs +3 -2
- package/templates/base/root/AGENTS.md.ejs +306 -45
- package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1663 -0
- package/templates/base/root/skill-build-system-yaml.ejs +1446 -0
- package/templates/base/root/system.yaml.ejs +97 -0
- package/templates/crud/ApplicationMapper.java.ejs +4 -0
- package/templates/crud/Controller.java.ejs +4 -4
- package/templates/crud/CreateCommand.java.ejs +4 -0
- package/templates/crud/CreateItemDto.java.ejs +4 -0
- package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
- package/templates/crud/DeleteCommandHandler.java.ejs +10 -2
- package/templates/crud/EndpointsController.java.ejs +178 -0
- package/templates/crud/FindByQuery.java.ejs +17 -0
- package/templates/crud/FindByQueryHandler.java.ejs +57 -0
- package/templates/crud/ListQuery.java.ejs +1 -1
- package/templates/crud/ListQueryHandler.java.ejs +8 -8
- package/templates/crud/ScaffoldCommand.java.ejs +12 -0
- package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
- package/templates/crud/ScaffoldQuery.java.ejs +13 -0
- package/templates/crud/ScaffoldQueryHandler.java.ejs +41 -0
- package/templates/crud/SubEntityAddCommand.java.ejs +21 -0
- package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
- package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
- package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
- package/templates/crud/TransitionCommand.java.ejs +9 -0
- package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
- package/templates/crud/UpdateCommand.java.ejs +4 -0
- package/templates/evaluate/report.html.ejs +1363 -0
- package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
- package/templates/kafka-event/Event.java.ejs +16 -0
- package/templates/kafka-listener/KafkaController.java.ejs +1 -1
- package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
- package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
- package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
- package/templates/kafka-listener/ListenerCommandHandler.java.ejs +23 -0
- package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
- package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
- package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
- package/templates/mock/MockEvent.java.ejs +10 -0
- package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
- package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
- package/templates/mock/SpringEventListener.java.ejs +61 -0
- package/templates/ports/PortDomainModel.java.ejs +35 -0
- package/templates/ports/PortFeignAdapter.java.ejs +67 -0
- package/templates/ports/PortFeignClient.java.ejs +45 -0
- package/templates/ports/PortFeignConfig.java.ejs +24 -0
- package/templates/ports/PortInterface.java.ejs +45 -0
- package/templates/ports/PortNestedType.java.ejs +28 -0
- package/templates/ports/PortRequestDto.java.ejs +30 -0
- package/templates/ports/PortResponseDto.java.ejs +28 -0
- package/templates/postman/Collection.json.ejs +1 -1
- package/templates/postman/UnifiedCollection.json.ejs +185 -0
- package/templates/shared/configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs +109 -0
|
@@ -5,15 +5,86 @@ const fs = require('fs-extra');
|
|
|
5
5
|
const inquirer = require('inquirer');
|
|
6
6
|
const ConfigManager = require('../utils/config-manager');
|
|
7
7
|
const { isEva4jProject } = require('../utils/validator');
|
|
8
|
-
const { toPackagePath, toCamelCase, toKebabCase, getApplicationClassName } = require('../utils/naming');
|
|
9
|
-
const { renderAndWrite } = require('../utils/template-engine');
|
|
8
|
+
const { toPackagePath, toCamelCase, toKebabCase, toPascalCase, getApplicationClassName, pluralizeWord } = require('../utils/naming');
|
|
9
|
+
const { renderAndWrite, renderTemplate } = require('../utils/template-engine');
|
|
10
10
|
const { parseDomainYaml, generateEntityImports, generateValidationImports } = require('../utils/yaml-to-entity');
|
|
11
|
+
const { createOrUpdateUrlsConfig, ensureUrlsImport } = require('./generate-http-exchange');
|
|
11
12
|
const SharedGenerator = require('../generators/shared-generator');
|
|
12
13
|
const ChecksumManager = require('../utils/checksum-manager');
|
|
14
|
+
const { generateFakeValue, initSeed } = require('../utils/fake-data');
|
|
15
|
+
const { getInstalledBroker, generateSingleKafkaEvent, buildKafkaEventContext, updateKafkaYml,
|
|
16
|
+
generateEventRecord, createOrUpdateMessageBroker, updateDomainEventHandler } = require('./generate-kafka-event');
|
|
13
17
|
|
|
14
18
|
// Maximum depth for recursive relationship traversal
|
|
15
19
|
const MAX_DEPTH = 5;
|
|
16
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Create or update {Module}KafkaMessageBroker with a mock implementation that
|
|
23
|
+
* uses ApplicationEventPublisher + MockEvent instead of Kafka.
|
|
24
|
+
* Mirrors the logic of createOrUpdateKafkaMessageBroker but uses mock templates.
|
|
25
|
+
*/
|
|
26
|
+
async function createOrUpdateMockMessageBroker(projectDir, packagePath, context) {
|
|
27
|
+
const adapterPath = path.join(
|
|
28
|
+
projectDir, 'src', 'main', 'java', packagePath,
|
|
29
|
+
context.moduleName, 'infrastructure', 'adapters', 'kafkaMessageBroker',
|
|
30
|
+
`${context.kafkaMessageBrokerClassName}.java`
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const methodName = `publish${context.eventClassName}`;
|
|
34
|
+
const mockTemplatesDir = path.join(__dirname, '..', '..', 'templates', 'mock');
|
|
35
|
+
|
|
36
|
+
if (await fs.pathExists(adapterPath)) {
|
|
37
|
+
let content = await fs.readFile(adapterPath, 'utf-8');
|
|
38
|
+
|
|
39
|
+
// If this file is still a Kafka implementation, replace it wholesale
|
|
40
|
+
const isKafkaImpl = content.includes('KafkaTemplate') || content.includes('kafkaTemplate');
|
|
41
|
+
if (isKafkaImpl) {
|
|
42
|
+
// Re-create from mock template (overwrite)
|
|
43
|
+
await renderAndWrite(
|
|
44
|
+
path.join(mockTemplatesDir, 'MockMessageBrokerImpl.java.ejs'),
|
|
45
|
+
adapterPath,
|
|
46
|
+
{ ...context, topicName: context.topicNameSnake },
|
|
47
|
+
{ force: true }
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Already a mock impl — check if method already exists
|
|
53
|
+
if (content.includes(methodName)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Inject event import if missing
|
|
58
|
+
const eventImport = `import ${context.packageName}.${context.moduleName}.application.events.${context.eventClassName};`;
|
|
59
|
+
if (!content.includes(eventImport)) {
|
|
60
|
+
const pkgMatch = content.match(/(package\s+[\w.]+;\s*\n)/);
|
|
61
|
+
if (pkgMatch) {
|
|
62
|
+
const imports = [...content.matchAll(/import\s+[\w.]+;\s*\n/g)];
|
|
63
|
+
const insertPos = imports.length
|
|
64
|
+
? imports[imports.length - 1].index + imports[imports.length - 1][0].length
|
|
65
|
+
: pkgMatch.index + pkgMatch[0].length;
|
|
66
|
+
content = content.slice(0, insertPos) + eventImport + '\n' + content.slice(insertPos);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Append mock method before the last closing brace
|
|
71
|
+
const methodSnippet = await renderTemplate(
|
|
72
|
+
path.join(mockTemplatesDir, 'MockMessageBrokerImplMethod.java.ejs'),
|
|
73
|
+
{ ...context, topicName: context.topicNameSnake }
|
|
74
|
+
);
|
|
75
|
+
const lastBrace = content.lastIndexOf('}');
|
|
76
|
+
content = content.slice(0, lastBrace) + '\n' + methodSnippet + '\n}\n';
|
|
77
|
+
await fs.writeFile(adapterPath, content, 'utf-8');
|
|
78
|
+
} else {
|
|
79
|
+
// Create new mock broker from template
|
|
80
|
+
await renderAndWrite(
|
|
81
|
+
path.join(mockTemplatesDir, 'MockMessageBrokerImpl.java.ejs'),
|
|
82
|
+
adapterPath,
|
|
83
|
+
{ ...context, topicName: context.topicNameSnake }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
17
88
|
/**
|
|
18
89
|
* Build a relationship graph for secondary entities
|
|
19
90
|
* @param {Array} secondaryEntities - Array of secondary entities
|
|
@@ -173,7 +244,7 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
173
244
|
|
|
174
245
|
try {
|
|
175
246
|
// Parse domain.yaml
|
|
176
|
-
const { aggregates, allEnums } = await parseDomainYaml(domainYamlPath, packageName, moduleName);
|
|
247
|
+
const { aggregates, allEnums, endpoints, listeners, ports } = await parseDomainYaml(domainYamlPath, packageName, moduleName);
|
|
177
248
|
|
|
178
249
|
spinner.succeed(chalk.green(`Found ${aggregates.length} aggregate(s) and ${allEnums.length} enum(s)`));
|
|
179
250
|
|
|
@@ -201,6 +272,15 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
201
272
|
await sharedGenerator.generateDomainEvent(sharedBasePath);
|
|
202
273
|
}
|
|
203
274
|
|
|
275
|
+
// Detect installed message broker for auto-wiring integration events
|
|
276
|
+
const installedBroker = (hasDomainEventsInModule || (listeners && listeners.length > 0))
|
|
277
|
+
? await getInstalledBroker(configManager)
|
|
278
|
+
: null;
|
|
279
|
+
// When brokerMode:'mock' is requested AND a broker is installed, use mock Spring-Events adapter
|
|
280
|
+
const broker = (options.brokerMode === 'mock' && installedBroker)
|
|
281
|
+
? 'mock'
|
|
282
|
+
: installedBroker;
|
|
283
|
+
|
|
204
284
|
// Generate audit-related shared components if needed
|
|
205
285
|
if (hasAuditableEntities || hasTrackUserEntities) {
|
|
206
286
|
|
|
@@ -299,7 +379,9 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
299
379
|
valueObjects,
|
|
300
380
|
aggregateMethods: aggregate.aggregateMethods,
|
|
301
381
|
auditable: rootEntity.auditable,
|
|
302
|
-
|
|
382
|
+
hasSoftDelete: rootEntity.hasSoftDelete || false,
|
|
383
|
+
domainEvents: aggregate.domainEvents || [],
|
|
384
|
+
triggeredEventsMap: aggregate.triggeredEventsMap || {}
|
|
303
385
|
};
|
|
304
386
|
|
|
305
387
|
await renderAndWrite(
|
|
@@ -322,7 +404,8 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
322
404
|
valueObjects,
|
|
323
405
|
enums: allEnums,
|
|
324
406
|
auditable: rootEntity.auditable,
|
|
325
|
-
audit: rootEntity.audit
|
|
407
|
+
audit: rootEntity.audit,
|
|
408
|
+
hasSoftDelete: rootEntity.hasSoftDelete || false
|
|
326
409
|
};
|
|
327
410
|
|
|
328
411
|
await renderAndWrite(
|
|
@@ -439,7 +522,9 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
439
522
|
const repoContext = {
|
|
440
523
|
packageName,
|
|
441
524
|
moduleName,
|
|
442
|
-
rootEntity
|
|
525
|
+
rootEntity,
|
|
526
|
+
hasSoftDelete: rootEntity.hasSoftDelete || false,
|
|
527
|
+
findByOps: []
|
|
443
528
|
};
|
|
444
529
|
|
|
445
530
|
await renderAndWrite(
|
|
@@ -465,7 +550,9 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
465
550
|
moduleName,
|
|
466
551
|
aggregateName,
|
|
467
552
|
rootEntity,
|
|
468
|
-
hasDomainEvents: (aggregate.domainEvents || []).length > 0
|
|
553
|
+
hasDomainEvents: (aggregate.domainEvents || []).length > 0,
|
|
554
|
+
hasSoftDelete: rootEntity.hasSoftDelete || false,
|
|
555
|
+
findByOps: []
|
|
469
556
|
};
|
|
470
557
|
|
|
471
558
|
await renderAndWrite(
|
|
@@ -503,8 +590,11 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
503
590
|
packageName,
|
|
504
591
|
moduleName,
|
|
505
592
|
aggregateName,
|
|
506
|
-
domainEvents: aggregateDomainEvents
|
|
507
|
-
|
|
593
|
+
domainEvents: aggregateDomainEvents.map(e => ({
|
|
594
|
+
...e,
|
|
595
|
+
integrationEventClassName: `${e.name}IntegrationEvent`
|
|
596
|
+
})),
|
|
597
|
+
broker
|
|
508
598
|
};
|
|
509
599
|
await renderAndWrite(
|
|
510
600
|
path.join(__dirname, '..', '..', 'templates', 'aggregate', 'DomainEventHandler.java.ejs'),
|
|
@@ -513,11 +603,475 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
513
603
|
writeOptions
|
|
514
604
|
);
|
|
515
605
|
generatedFiles.push({ type: 'Domain Event Handler', name: `${aggregateName}DomainEventHandler`, path: `${moduleName}/application/usecases/${aggregateName}DomainEventHandler.java` });
|
|
606
|
+
|
|
607
|
+
// ── Auto-wire broker integration events ────────────────────────────────
|
|
608
|
+
// When a message broker is installed, generate the complete integration
|
|
609
|
+
// event layer (XIntegrationEvent record, MessageBroker port method,
|
|
610
|
+
// KafkaMessageBroker impl method, topic config, KafkaConfig bean) for
|
|
611
|
+
// every domain event declared in this aggregate.
|
|
612
|
+
if (broker === 'kafka') {
|
|
613
|
+
const STANDARD_EVENT_TYPES = new Set([
|
|
614
|
+
'String','Integer','Long','Double','Float','Boolean',
|
|
615
|
+
'BigDecimal','LocalDate','LocalDateTime','LocalTime','Instant','UUID'
|
|
616
|
+
]);
|
|
617
|
+
for (const event of aggregateDomainEvents) {
|
|
618
|
+
const kafkaCtx = buildKafkaEventContext(packageName, moduleName, event);
|
|
619
|
+
await generateSingleKafkaEvent(projectDir, packagePath, kafkaCtx);
|
|
620
|
+
generatedFiles.push({
|
|
621
|
+
type: 'Integration Event',
|
|
622
|
+
name: kafkaCtx.eventClassName,
|
|
623
|
+
path: `${moduleName}/application/events/${kafkaCtx.eventClassName}.java`
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Generate stub records for custom collection element types
|
|
627
|
+
// e.g. List<OrderItemSnapshot> → generate OrderItemSnapshot.java
|
|
628
|
+
const customElementTypes = [...new Set(
|
|
629
|
+
(event.fields || [])
|
|
630
|
+
.filter(f => f.isCollection && f.collectionElementType && !STANDARD_EVENT_TYPES.has(f.collectionElementType))
|
|
631
|
+
.map(f => f.collectionElementType)
|
|
632
|
+
)];
|
|
633
|
+
for (const typeName of customElementTypes) {
|
|
634
|
+
const stubPath = path.join(moduleBasePath, 'domain', 'models', 'events', `${typeName}.java`);
|
|
635
|
+
await renderAndWrite(
|
|
636
|
+
path.join(__dirname, '..', '..', 'templates', 'aggregate', 'DomainEventSnapshot.java.ejs'),
|
|
637
|
+
stubPath,
|
|
638
|
+
{ packageName, moduleName, name: typeName, fields: [] },
|
|
639
|
+
{ ...writeOptions, overwrite: false }
|
|
640
|
+
);
|
|
641
|
+
generatedFiles.push({
|
|
642
|
+
type: 'Event Snapshot Type',
|
|
643
|
+
name: typeName,
|
|
644
|
+
path: `${moduleName}/domain/models/events/${typeName}.java`
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
generatedFiles.push({
|
|
649
|
+
type: 'Integration Event',
|
|
650
|
+
name: `${toPascalCase(moduleName)}KafkaMessageBroker (updated)`,
|
|
651
|
+
path: `${moduleName}/infrastructure/adapters/kafkaMessageBroker/${toPascalCase(moduleName)}KafkaMessageBroker.java`
|
|
652
|
+
});
|
|
653
|
+
generatedFiles.push({
|
|
654
|
+
type: 'Integration Event',
|
|
655
|
+
name: 'MessageBroker (updated)',
|
|
656
|
+
path: `${moduleName}/application/ports/MessageBroker.java`
|
|
657
|
+
});
|
|
658
|
+
} else if (broker === 'mock') {
|
|
659
|
+
// ── Mock broker: Spring ApplicationEventPublisher instead of Kafka ──
|
|
660
|
+
// Generate MockEvent record in shared once (idempotent)
|
|
661
|
+
await sharedGenerator.generateMockEvent(sharedBasePath);
|
|
662
|
+
|
|
663
|
+
for (const event of aggregateDomainEvents) {
|
|
664
|
+
const kafkaCtx = buildKafkaEventContext(packageName, moduleName, event);
|
|
665
|
+
// 1. Integration Event record (same as Kafka — only the adapter differs)
|
|
666
|
+
await generateEventRecord(projectDir, packagePath, kafkaCtx);
|
|
667
|
+
generatedFiles.push({
|
|
668
|
+
type: 'Integration Event',
|
|
669
|
+
name: kafkaCtx.eventClassName,
|
|
670
|
+
path: `${moduleName}/application/events/${kafkaCtx.eventClassName}.java`
|
|
671
|
+
});
|
|
672
|
+
// 2. MessageBroker port interface (same as Kafka)
|
|
673
|
+
await createOrUpdateMessageBroker(projectDir, packagePath, kafkaCtx);
|
|
674
|
+
// 3. Mock KafkaMessageBroker impl (uses ApplicationEventPublisher)
|
|
675
|
+
await createOrUpdateMockMessageBroker(projectDir, packagePath, kafkaCtx);
|
|
676
|
+
// 4. DomainEventHandler (same as Kafka — calls messageBroker.publishX)
|
|
677
|
+
await updateDomainEventHandler(projectDir, packagePath, kafkaCtx);
|
|
678
|
+
// 5. NO kafka.yaml update and NO KafkaConfig update in mock mode
|
|
679
|
+
}
|
|
680
|
+
generatedFiles.push({
|
|
681
|
+
type: 'Integration Event',
|
|
682
|
+
name: `${toPascalCase(moduleName)}KafkaMessageBroker (mock)`,
|
|
683
|
+
path: `${moduleName}/infrastructure/adapters/kafkaMessageBroker/${toPascalCase(moduleName)}KafkaMessageBroker.java`
|
|
684
|
+
});
|
|
685
|
+
generatedFiles.push({
|
|
686
|
+
type: 'Integration Event',
|
|
687
|
+
name: 'MessageBroker (updated)',
|
|
688
|
+
path: `${moduleName}/application/ports/MessageBroker.java`
|
|
689
|
+
});
|
|
690
|
+
}
|
|
516
691
|
}
|
|
517
692
|
}
|
|
518
693
|
|
|
519
694
|
spinner.succeed(chalk.green(`Generated ${generatedFiles.length} files! ✨`));
|
|
520
695
|
|
|
696
|
+
// ── Generate listeners (integration events CONSUMED from external producers) ──
|
|
697
|
+
if (listeners && listeners.length > 0) {
|
|
698
|
+
if (broker === 'kafka') {
|
|
699
|
+
spinner.start(`Generating ${listeners.length} Kafka listener(s)...`);
|
|
700
|
+
for (const listener of listeners) {
|
|
701
|
+
// Validate topic presence (mandatory for standalone modules)
|
|
702
|
+
if (!listener.topic) {
|
|
703
|
+
spinner.warn(chalk.yellow(`⚠ listener '${listener.event}': topic is required when there is no system.yaml. Skipping.`));
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Strip any prefix before the last dot (e.g. "test-eva.ORDER_PLACED" → "ORDER_PLACED")
|
|
708
|
+
const topicRaw = listener.topic;
|
|
709
|
+
const topicSuffix = topicRaw.includes('.') ? topicRaw.slice(topicRaw.lastIndexOf('.') + 1) : topicRaw;
|
|
710
|
+
const topicKey = topicSuffix.toLowerCase().replace(/_/g, '-');
|
|
711
|
+
const listenerContext = {
|
|
712
|
+
packageName,
|
|
713
|
+
moduleName,
|
|
714
|
+
...listener,
|
|
715
|
+
topicConstant: topicRaw,
|
|
716
|
+
topicSpringProperty: `\${topics.${topicKey}}`,
|
|
717
|
+
topicVariableName: toCamelCase(topicSuffix.toLowerCase())
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// 0. Nested type records (auxiliary value objects for object-typed fields)
|
|
721
|
+
for (const nt of (listener.nestedTypes || [])) {
|
|
722
|
+
const ntPath = path.join(
|
|
723
|
+
moduleBasePath, 'application', 'events',
|
|
724
|
+
`${nt.name}.java`
|
|
725
|
+
);
|
|
726
|
+
await renderAndWrite(
|
|
727
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerNestedType.java.ejs'),
|
|
728
|
+
ntPath,
|
|
729
|
+
{ packageName, moduleName, name: nt.name, fields: nt.fields },
|
|
730
|
+
writeOptions
|
|
731
|
+
);
|
|
732
|
+
generatedFiles.push({
|
|
733
|
+
type: 'Listener Nested Type',
|
|
734
|
+
name: nt.name,
|
|
735
|
+
path: `${moduleName}/application/events/${nt.name}.java`
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// 1. Integration Event record
|
|
740
|
+
const integrationEventPath = path.join(
|
|
741
|
+
moduleBasePath, 'application', 'events',
|
|
742
|
+
`${listener.integrationEventClassName}.java`
|
|
743
|
+
);
|
|
744
|
+
await renderAndWrite(
|
|
745
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerIntegrationEvent.java.ejs'),
|
|
746
|
+
integrationEventPath,
|
|
747
|
+
listenerContext,
|
|
748
|
+
writeOptions
|
|
749
|
+
);
|
|
750
|
+
generatedFiles.push({
|
|
751
|
+
type: 'Listener Integration Event',
|
|
752
|
+
name: listener.integrationEventClassName,
|
|
753
|
+
path: `${moduleName}/application/events/${listener.integrationEventClassName}.java`
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// 2. Kafka listener class
|
|
757
|
+
// If the file is currently a mock Spring listener, force overwrite with the real Kafka impl
|
|
758
|
+
const kafkaListenerPath = path.join(
|
|
759
|
+
moduleBasePath, 'infrastructure', 'kafkaListener',
|
|
760
|
+
`${listener.listenerClassName}.java`
|
|
761
|
+
);
|
|
762
|
+
let kafkaListenerWriteOpts = writeOptions;
|
|
763
|
+
if (await fs.pathExists(kafkaListenerPath)) {
|
|
764
|
+
const existing = await fs.readFile(kafkaListenerPath, 'utf-8');
|
|
765
|
+
if (existing.includes('@EventListener') && !existing.includes('@KafkaListener')) {
|
|
766
|
+
kafkaListenerWriteOpts = { ...writeOptions, force: true };
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
await renderAndWrite(
|
|
770
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerClass.java.ejs'),
|
|
771
|
+
kafkaListenerPath,
|
|
772
|
+
listenerContext,
|
|
773
|
+
kafkaListenerWriteOpts
|
|
774
|
+
);
|
|
775
|
+
generatedFiles.push({
|
|
776
|
+
type: 'Kafka Listener',
|
|
777
|
+
name: listener.listenerClassName,
|
|
778
|
+
path: `${moduleName}/infrastructure/kafkaListener/${listener.listenerClassName}.java`
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// 3. Register topic in kafka.yaml (all environments)
|
|
782
|
+
await updateKafkaYml(projectDir, topicKey, listener.topic);
|
|
783
|
+
|
|
784
|
+
// 4. Typed Command dispatched from the listener
|
|
785
|
+
const commandPath = path.join(
|
|
786
|
+
moduleBasePath, 'application', 'commands',
|
|
787
|
+
`${listener.commandClassName}.java`
|
|
788
|
+
);
|
|
789
|
+
await renderAndWrite(
|
|
790
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerCommand.java.ejs'),
|
|
791
|
+
commandPath,
|
|
792
|
+
listenerContext,
|
|
793
|
+
writeOptions
|
|
794
|
+
);
|
|
795
|
+
generatedFiles.push({
|
|
796
|
+
type: 'Listener Command',
|
|
797
|
+
name: listener.commandClassName,
|
|
798
|
+
path: `${moduleName}/application/commands/${listener.commandClassName}.java`
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// 5. Use case handler that processes the command
|
|
802
|
+
const handlerPath = path.join(
|
|
803
|
+
moduleBasePath, 'application', 'usecases',
|
|
804
|
+
`${listener.useCase}CommandHandler.java`
|
|
805
|
+
);
|
|
806
|
+
await renderAndWrite(
|
|
807
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerCommandHandler.java.ejs'),
|
|
808
|
+
handlerPath,
|
|
809
|
+
listenerContext,
|
|
810
|
+
writeOptions
|
|
811
|
+
);
|
|
812
|
+
generatedFiles.push({
|
|
813
|
+
type: 'Handler',
|
|
814
|
+
name: `${listener.useCase}CommandHandler`,
|
|
815
|
+
path: `${moduleName}/application/usecases/${listener.useCase}CommandHandler.java`
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
spinner.succeed(chalk.green(`Kafka listeners generated! ✨`));
|
|
819
|
+
} else if (broker === 'mock') {
|
|
820
|
+
// ── Mock listeners: @EventListener instead of @KafkaListener ──────────
|
|
821
|
+
spinner.start(`Generating ${listeners.length} Spring Event listener(s) (mock mode)...`);
|
|
822
|
+
|
|
823
|
+
// Ensure MockEvent is available in shared
|
|
824
|
+
await sharedGenerator.generateMockEvent(sharedBasePath);
|
|
825
|
+
|
|
826
|
+
for (const listener of listeners) {
|
|
827
|
+
if (!listener.topic) {
|
|
828
|
+
spinner.warn(chalk.yellow(`⚠ listener '${listener.event}': topic is required. Skipping.`));
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const topicRaw = listener.topic;
|
|
833
|
+
const topicSuffix = topicRaw.includes('.') ? topicRaw.slice(topicRaw.lastIndexOf('.') + 1) : topicRaw;
|
|
834
|
+
const topicKey = topicSuffix.toLowerCase().replace(/_/g, '-');
|
|
835
|
+
const listenerContext = {
|
|
836
|
+
packageName,
|
|
837
|
+
moduleName,
|
|
838
|
+
...listener,
|
|
839
|
+
topicConstant: topicRaw,
|
|
840
|
+
topicSpringProperty: `\${topics.${topicKey}}`,
|
|
841
|
+
topicVariableName: toCamelCase(topicSuffix.toLowerCase())
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
// 0. Nested type records
|
|
845
|
+
for (const nt of (listener.nestedTypes || [])) {
|
|
846
|
+
const ntPath = path.join(moduleBasePath, 'application', 'events', `${nt.name}.java`);
|
|
847
|
+
await renderAndWrite(
|
|
848
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerNestedType.java.ejs'),
|
|
849
|
+
ntPath,
|
|
850
|
+
{ packageName, moduleName, name: nt.name, fields: nt.fields },
|
|
851
|
+
writeOptions
|
|
852
|
+
);
|
|
853
|
+
generatedFiles.push({ type: 'Listener Nested Type', name: nt.name, path: `${moduleName}/application/events/${nt.name}.java` });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// 1. Integration Event record (same as Kafka)
|
|
857
|
+
const integrationEventPath = path.join(moduleBasePath, 'application', 'events', `${listener.integrationEventClassName}.java`);
|
|
858
|
+
await renderAndWrite(
|
|
859
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerIntegrationEvent.java.ejs'),
|
|
860
|
+
integrationEventPath,
|
|
861
|
+
listenerContext,
|
|
862
|
+
writeOptions
|
|
863
|
+
);
|
|
864
|
+
generatedFiles.push({ type: 'Listener Integration Event', name: listener.integrationEventClassName, path: `${moduleName}/application/events/${listener.integrationEventClassName}.java` });
|
|
865
|
+
|
|
866
|
+
// 2. Spring @EventListener class (mock — same file path as Kafka listener)
|
|
867
|
+
const listenerPath = path.join(moduleBasePath, 'infrastructure', 'kafkaListener', `${listener.listenerClassName}.java`);
|
|
868
|
+
await renderAndWrite(
|
|
869
|
+
path.join(__dirname, '..', '..', 'templates', 'mock', 'SpringEventListener.java.ejs'),
|
|
870
|
+
listenerPath,
|
|
871
|
+
listenerContext,
|
|
872
|
+
{ ...writeOptions, force: true }
|
|
873
|
+
);
|
|
874
|
+
generatedFiles.push({ type: 'Spring Listener (mock)', name: listener.listenerClassName, path: `${moduleName}/infrastructure/kafkaListener/${listener.listenerClassName}.java` });
|
|
875
|
+
|
|
876
|
+
// 3. NO kafka.yaml update in mock mode
|
|
877
|
+
|
|
878
|
+
// 4. Typed Command dispatched from the listener
|
|
879
|
+
const mockCommandPath = path.join(moduleBasePath, 'application', 'commands', `${listener.commandClassName}.java`);
|
|
880
|
+
await renderAndWrite(
|
|
881
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerCommand.java.ejs'),
|
|
882
|
+
mockCommandPath,
|
|
883
|
+
listenerContext,
|
|
884
|
+
writeOptions
|
|
885
|
+
);
|
|
886
|
+
generatedFiles.push({ type: 'Listener Command', name: listener.commandClassName, path: `${moduleName}/application/commands/${listener.commandClassName}.java` });
|
|
887
|
+
|
|
888
|
+
// 5. Use case handler stub
|
|
889
|
+
const mockHandlerPath = path.join(moduleBasePath, 'application', 'usecases', `${listener.useCase}CommandHandler.java`);
|
|
890
|
+
await renderAndWrite(
|
|
891
|
+
path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerCommandHandler.java.ejs'),
|
|
892
|
+
mockHandlerPath,
|
|
893
|
+
listenerContext,
|
|
894
|
+
writeOptions
|
|
895
|
+
);
|
|
896
|
+
generatedFiles.push({ type: 'Handler', name: `${listener.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${listener.useCase}CommandHandler.java` });
|
|
897
|
+
}
|
|
898
|
+
spinner.succeed(chalk.green(`Spring Event listeners generated (mock mode)! ✨`));
|
|
899
|
+
} else if (listeners.length > 0) {
|
|
900
|
+
console.log(chalk.yellow(`⚠ listeners: section found but no broker is installed. Run 'eva add kafka-client' to generate listener classes.`));
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── Generate ports (HTTP clients for synchronous communication) ──────────
|
|
905
|
+
if (ports && ports.length > 0) {
|
|
906
|
+
spinner.start(`Generating ${ports.length} HTTP port(s)...`);
|
|
907
|
+
|
|
908
|
+
for (const portGroup of ports) {
|
|
909
|
+
const {
|
|
910
|
+
serviceName,
|
|
911
|
+
serviceNameCamelCase,
|
|
912
|
+
target,
|
|
913
|
+
baseUrl,
|
|
914
|
+
baseUrlProperty,
|
|
915
|
+
feignClientName,
|
|
916
|
+
feignClientClassName,
|
|
917
|
+
feignAdapterClassName,
|
|
918
|
+
feignConfigClassName,
|
|
919
|
+
adapterPackage,
|
|
920
|
+
methods,
|
|
921
|
+
nestedTypes,
|
|
922
|
+
domainModels
|
|
923
|
+
} = portGroup;
|
|
924
|
+
|
|
925
|
+
const adapterDir = path.join(moduleBasePath, 'infrastructure', 'adapters', adapterPackage);
|
|
926
|
+
|
|
927
|
+
const portContext = {
|
|
928
|
+
packageName,
|
|
929
|
+
moduleName,
|
|
930
|
+
serviceName,
|
|
931
|
+
serviceNameCamelCase,
|
|
932
|
+
target,
|
|
933
|
+
baseUrl,
|
|
934
|
+
baseUrlProperty,
|
|
935
|
+
feignClientName,
|
|
936
|
+
feignClientClassName,
|
|
937
|
+
feignAdapterClassName,
|
|
938
|
+
feignConfigClassName,
|
|
939
|
+
adapterPackage,
|
|
940
|
+
methods,
|
|
941
|
+
nestedTypes,
|
|
942
|
+
domainModels
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
// 0. Nested type records (shared across methods in the same service)
|
|
946
|
+
for (const nt of nestedTypes) {
|
|
947
|
+
const ntPath = path.join(
|
|
948
|
+
moduleBasePath, 'application', 'dtos', `${nt.name}.java`
|
|
949
|
+
);
|
|
950
|
+
await renderAndWrite(
|
|
951
|
+
path.join(__dirname, '..', '..', 'templates', 'ports', 'PortNestedType.java.ejs'),
|
|
952
|
+
ntPath,
|
|
953
|
+
{ packageName, moduleName, name: nt.name, fields: nt.fields },
|
|
954
|
+
writeOptions
|
|
955
|
+
);
|
|
956
|
+
generatedFiles.push({
|
|
957
|
+
type: 'Port DTO',
|
|
958
|
+
name: nt.name,
|
|
959
|
+
path: `${moduleName}/application/dtos/${nt.name}.java`
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// 1a. Domain models in domain/models/{adapterPackage}/ (ACL: domain-side abstraction)
|
|
964
|
+
for (const dm of (domainModels || [])) {
|
|
965
|
+
const dmPath = path.join(moduleBasePath, 'domain', 'models', adapterPackage, `${dm.name}.java`);
|
|
966
|
+
await renderAndWrite(
|
|
967
|
+
path.join(__dirname, '..', '..', 'templates', 'ports', 'PortDomainModel.java.ejs'),
|
|
968
|
+
dmPath,
|
|
969
|
+
{ packageName, moduleName, name: dm.name, fields: dm.fields, target, serviceName, adapterPackage },
|
|
970
|
+
writeOptions
|
|
971
|
+
);
|
|
972
|
+
generatedFiles.push({
|
|
973
|
+
type: 'Port Domain Model',
|
|
974
|
+
name: dm.name,
|
|
975
|
+
path: `${moduleName}/domain/models/${adapterPackage}/${dm.name}.java`
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// 1b. Infra DTOs (one per method that has fields:) — live in infrastructure/adapters/{service}/
|
|
980
|
+
for (const method of methods.filter(m => m.hasResponse)) {
|
|
981
|
+
const infraDtoPath = path.join(adapterDir, `${method.infraDtoName}.java`);
|
|
982
|
+
await renderAndWrite(
|
|
983
|
+
path.join(__dirname, '..', '..', 'templates', 'ports', 'PortResponseDto.java.ejs'),
|
|
984
|
+
infraDtoPath,
|
|
985
|
+
{ packageName, moduleName, dtoName: method.infraDtoName, fields: method.fields, adapterPackage },
|
|
986
|
+
writeOptions
|
|
987
|
+
);
|
|
988
|
+
generatedFiles.push({
|
|
989
|
+
type: 'Port Infra DTO',
|
|
990
|
+
name: method.infraDtoName,
|
|
991
|
+
path: `${moduleName}/infrastructure/adapters/${adapterPackage}/${method.infraDtoName}.java`
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// 2. Request DTOs (one per method that has body:)
|
|
996
|
+
for (const method of methods.filter(m => m.hasBody)) {
|
|
997
|
+
const reqPath = path.join(
|
|
998
|
+
moduleBasePath, 'application', 'dtos', `${method.requestDtoName}.java`
|
|
999
|
+
);
|
|
1000
|
+
await renderAndWrite(
|
|
1001
|
+
path.join(__dirname, '..', '..', 'templates', 'ports', 'PortRequestDto.java.ejs'),
|
|
1002
|
+
reqPath,
|
|
1003
|
+
{ packageName, moduleName, dtoName: method.requestDtoName, bodyFields: method.bodyFields, nestedTypes: method.nestedTypes },
|
|
1004
|
+
writeOptions
|
|
1005
|
+
);
|
|
1006
|
+
generatedFiles.push({
|
|
1007
|
+
type: 'Port DTO',
|
|
1008
|
+
name: method.requestDtoName,
|
|
1009
|
+
path: `${moduleName}/application/dtos/${method.requestDtoName}.java`
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// 3. Port interface (domain/repositories/)
|
|
1014
|
+
await renderAndWrite(
|
|
1015
|
+
path.join(__dirname, '..', '..', 'templates', 'ports', 'PortInterface.java.ejs'),
|
|
1016
|
+
path.join(moduleBasePath, 'domain', 'repositories', `${serviceName}.java`),
|
|
1017
|
+
portContext,
|
|
1018
|
+
writeOptions
|
|
1019
|
+
);
|
|
1020
|
+
generatedFiles.push({
|
|
1021
|
+
type: 'HTTP Port',
|
|
1022
|
+
name: serviceName,
|
|
1023
|
+
path: `${moduleName}/domain/repositories/${serviceName}.java`
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// 4. Feign Client interface
|
|
1027
|
+
await renderAndWrite(
|
|
1028
|
+
path.join(__dirname, '..', '..', 'templates', 'ports', 'PortFeignClient.java.ejs'),
|
|
1029
|
+
path.join(adapterDir, `${feignClientClassName}.java`),
|
|
1030
|
+
portContext,
|
|
1031
|
+
writeOptions
|
|
1032
|
+
);
|
|
1033
|
+
generatedFiles.push({
|
|
1034
|
+
type: 'HTTP Port',
|
|
1035
|
+
name: feignClientClassName,
|
|
1036
|
+
path: `${moduleName}/infrastructure/adapters/${adapterPackage}/${feignClientClassName}.java`
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// 5. Feign Adapter (@Component implementation)
|
|
1040
|
+
await renderAndWrite(
|
|
1041
|
+
path.join(__dirname, '..', '..', 'templates', 'ports', 'PortFeignAdapter.java.ejs'),
|
|
1042
|
+
path.join(adapterDir, `${feignAdapterClassName}.java`),
|
|
1043
|
+
portContext,
|
|
1044
|
+
writeOptions
|
|
1045
|
+
);
|
|
1046
|
+
generatedFiles.push({
|
|
1047
|
+
type: 'HTTP Port',
|
|
1048
|
+
name: feignAdapterClassName,
|
|
1049
|
+
path: `${moduleName}/infrastructure/adapters/${adapterPackage}/${feignAdapterClassName}.java`
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// 6. Feign Config
|
|
1053
|
+
await renderAndWrite(
|
|
1054
|
+
path.join(__dirname, '..', '..', 'templates', 'ports', 'PortFeignConfig.java.ejs'),
|
|
1055
|
+
path.join(adapterDir, `${feignConfigClassName}.java`),
|
|
1056
|
+
portContext,
|
|
1057
|
+
writeOptions
|
|
1058
|
+
);
|
|
1059
|
+
generatedFiles.push({
|
|
1060
|
+
type: 'HTTP Port',
|
|
1061
|
+
name: feignConfigClassName,
|
|
1062
|
+
path: `${moduleName}/infrastructure/adapters/${adapterPackage}/${feignConfigClassName}.java`
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// 7. Register base URL in parameters/*/urls.yaml
|
|
1066
|
+
await createOrUpdateUrlsConfig(projectDir, baseUrlProperty, baseUrl);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Ensure urls.yaml is imported in all application-*.yaml files
|
|
1070
|
+
await ensureUrlsImport(projectDir);
|
|
1071
|
+
|
|
1072
|
+
spinner.succeed(chalk.green(`HTTP ports generated! ✨`));
|
|
1073
|
+
}
|
|
1074
|
+
|
|
521
1075
|
console.log(chalk.blue('\n📦 Generated files:'));
|
|
522
1076
|
const groupedFiles = generatedFiles.reduce((acc, file) => {
|
|
523
1077
|
if (!acc[file.type]) acc[file.type] = [];
|
|
@@ -541,15 +1095,87 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
541
1095
|
// Persist checksums to disk before asking about CRUD
|
|
542
1096
|
await checksumManager.save();
|
|
543
1097
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1098
|
+
if (endpoints) {
|
|
1099
|
+
// ── endpoints: section declared → skip CRUD prompt, auto-generate ──
|
|
1100
|
+
spinner.start('Generating endpoint-driven resources...');
|
|
1101
|
+
|
|
1102
|
+
// Pre-classify all operations ONCE against all aggregates so that
|
|
1103
|
+
// each per-aggregate pass never overwrites a classification that
|
|
1104
|
+
// belongs to a different aggregate (multi-aggregate modules).
|
|
1105
|
+
for (const version of endpoints.versions) {
|
|
1106
|
+
for (const op of version.operations) {
|
|
1107
|
+
for (const agg of aggregates) {
|
|
1108
|
+
const cl = classifyUseCase(op, agg.name, agg);
|
|
1109
|
+
if (cl.category !== 'scaffold') {
|
|
1110
|
+
op._ownerAggregate = agg.name;
|
|
1111
|
+
op._classification = cl;
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (!op._ownerAggregate) {
|
|
1116
|
+
// True scaffold: heuristic — find aggregate whose name appears inside the use case name
|
|
1117
|
+
const matched = aggregates.find(agg =>
|
|
1118
|
+
op.useCase.toLowerCase().includes(agg.name.toLowerCase())
|
|
1119
|
+
);
|
|
1120
|
+
const owner = matched || aggregates[0];
|
|
1121
|
+
op._ownerAggregate = owner.name;
|
|
1122
|
+
op._classification = { category: 'scaffold' };
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const sharedGeneratedUseCases = new Set();
|
|
1128
|
+
for (const aggregate of aggregates) {
|
|
1129
|
+
await generateEndpointsResources(
|
|
1130
|
+
aggregate,
|
|
1131
|
+
endpoints,
|
|
1132
|
+
moduleName,
|
|
1133
|
+
moduleBasePath,
|
|
1134
|
+
packageName,
|
|
1135
|
+
generatedFiles,
|
|
1136
|
+
writeOptions,
|
|
1137
|
+
sharedGeneratedUseCases
|
|
1138
|
+
);
|
|
551
1139
|
}
|
|
552
|
-
|
|
1140
|
+
|
|
1141
|
+
spinner.succeed(chalk.green('Endpoint-driven resources generated! ✨'));
|
|
1142
|
+
|
|
1143
|
+
const epFiles = generatedFiles.filter(f =>
|
|
1144
|
+
f.type.includes('Command') || f.type.includes('Query') ||
|
|
1145
|
+
f.type.includes('Handler') || f.type.includes('DTO') ||
|
|
1146
|
+
f.type.includes('Controller') || f.type.includes('Mapper')
|
|
1147
|
+
);
|
|
1148
|
+
console.log(chalk.blue('\n📄 Generated endpoint files:'));
|
|
1149
|
+
const groupedEp = epFiles.reduce((acc, file) => {
|
|
1150
|
+
if (!acc[file.type]) acc[file.type] = [];
|
|
1151
|
+
acc[file.type].push(file);
|
|
1152
|
+
return acc;
|
|
1153
|
+
}, {});
|
|
1154
|
+
Object.keys(groupedEp).forEach(type => {
|
|
1155
|
+
console.log(chalk.gray(`\n ${type}:`));
|
|
1156
|
+
groupedEp[type].forEach(file => {
|
|
1157
|
+
console.log(chalk.gray(` ├── ${file.name}`));
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
// Collect versions for summary
|
|
1162
|
+
const versionList = endpoints.versions.map(v => v.version).join(', ');
|
|
1163
|
+
const totalOps = endpoints.versions.reduce((sum, v) => sum + v.operations.length, 0);
|
|
1164
|
+
console.log(chalk.blue(`\n✅ Generated ${totalOps} endpoint(s) across version(s): ${versionList}`));
|
|
1165
|
+
|
|
1166
|
+
await checksumManager.save();
|
|
1167
|
+
|
|
1168
|
+
} else {
|
|
1169
|
+
// ── No endpoints section → original interactive CRUD flow ────────
|
|
1170
|
+
// Ask user if they want to generate CRUD resources
|
|
1171
|
+
const { generateCrud } = await inquirer.prompt([
|
|
1172
|
+
{
|
|
1173
|
+
type: 'confirm',
|
|
1174
|
+
name: 'generateCrud',
|
|
1175
|
+
message: 'Do you want to generate CRUD resources for aggregate roots?',
|
|
1176
|
+
default: true
|
|
1177
|
+
}
|
|
1178
|
+
]);
|
|
553
1179
|
|
|
554
1180
|
if (generateCrud) {
|
|
555
1181
|
// Ask for API version
|
|
@@ -583,22 +1209,24 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
583
1209
|
writeOptions
|
|
584
1210
|
);
|
|
585
1211
|
|
|
586
|
-
// Generate Postman Collection for this aggregate
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1212
|
+
// Generate Postman Collection for this aggregate (skip when called from eva build)
|
|
1213
|
+
if (!options.skipPostman) {
|
|
1214
|
+
const collectionPath = await generatePostmanCollection(
|
|
1215
|
+
aggregate,
|
|
1216
|
+
moduleName,
|
|
1217
|
+
moduleBasePath,
|
|
1218
|
+
projectDir,
|
|
1219
|
+
packageName,
|
|
1220
|
+
apiVersion,
|
|
1221
|
+
projectConfig,
|
|
1222
|
+
allEnums,
|
|
1223
|
+
writeOptions
|
|
1224
|
+
);
|
|
1225
|
+
postmanCollections.push({
|
|
1226
|
+
name: aggregate.name,
|
|
1227
|
+
path: path.relative(projectDir, collectionPath)
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
602
1230
|
}
|
|
603
1231
|
|
|
604
1232
|
spinner.succeed(chalk.green('CRUD resources generated! ✨'));
|
|
@@ -641,6 +1269,8 @@ async function generateEntitiesCommand(moduleName, options = {}) {
|
|
|
641
1269
|
await checksumManager.save();
|
|
642
1270
|
}
|
|
643
1271
|
|
|
1272
|
+
} // end else (no endpoints section)
|
|
1273
|
+
|
|
644
1274
|
console.log();
|
|
645
1275
|
|
|
646
1276
|
} catch (error) {
|
|
@@ -686,20 +1316,659 @@ function transformRelsForApp(rels, validatedVoNames) {
|
|
|
686
1316
|
}));
|
|
687
1317
|
}
|
|
688
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* Enrich fields with @Schema(example) values for Swagger UI.
|
|
1321
|
+
* Uses the same fake-data heuristics as the Postman collection generator.
|
|
1322
|
+
*/
|
|
1323
|
+
function enrichFieldsWithSchemaExamples(fields, allEnums, valueObjects) {
|
|
1324
|
+
return fields.map(f => {
|
|
1325
|
+
// Skip VOs (nested DTOs describe themselves), collections, and already-enriched
|
|
1326
|
+
if (f.originalVoType || f.isValueObject || f.isCollection) return f;
|
|
1327
|
+
const example = generateFakeValue(f, allEnums, valueObjects);
|
|
1328
|
+
if (example === null || example === undefined || typeof example === 'object') return f;
|
|
1329
|
+
return { ...f, schemaExample: String(example) };
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Recursively enrich relationship fields with schema examples.
|
|
1335
|
+
*/
|
|
1336
|
+
function enrichRelsWithSchemaExamples(rels, allEnums, valueObjects) {
|
|
1337
|
+
return (rels || []).map(rel => ({
|
|
1338
|
+
...rel,
|
|
1339
|
+
fields: enrichFieldsWithSchemaExamples(rel.fields || [], allEnums, valueObjects),
|
|
1340
|
+
nestedRelationships: enrichRelsWithSchemaExamples(rel.nestedRelationships, allEnums, valueObjects),
|
|
1341
|
+
nestedOneToOneRelationships: (rel.nestedOneToOneRelationships || []).map(otoRel => ({
|
|
1342
|
+
...otoRel,
|
|
1343
|
+
fields: enrichFieldsWithSchemaExamples(otoRel.fields || [], allEnums, valueObjects)
|
|
1344
|
+
}))
|
|
1345
|
+
}));
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Classify an endpoint operation into a semantic category.
|
|
1350
|
+
* Returns { category, ...metadata } where category is one of:
|
|
1351
|
+
* 'standard' → matches the 5 CRUD patterns exactly
|
|
1352
|
+
* 'transition' → matches {MethodPascal}{Aggregate} for an enum transition
|
|
1353
|
+
* 'subEntityAdd' → matches Add{EntityName} for a OneToMany secondary entity
|
|
1354
|
+
* 'subEntityRemove' → matches Remove{EntityName} for a OneToMany secondary entity
|
|
1355
|
+
* 'findBy' → matches FindAll{Aggregate}sBy{FieldPascal} for a root field
|
|
1356
|
+
* 'scaffold' → no semantic pattern matched
|
|
1357
|
+
*/
|
|
1358
|
+
function classifyUseCase(op, aggregateName, aggregate) {
|
|
1359
|
+
// 1. Standard CRUD
|
|
1360
|
+
const aggregateNamePlural = pluralizeWord(aggregateName);
|
|
1361
|
+
const standardMap = {
|
|
1362
|
+
[`Create${aggregateName}`]: 'create',
|
|
1363
|
+
[`Update${aggregateName}`]: 'update',
|
|
1364
|
+
[`Delete${aggregateName}`]: 'delete',
|
|
1365
|
+
[`Get${aggregateName}`]: 'getById',
|
|
1366
|
+
[`FindAll${aggregateNamePlural}`]: 'findAll'
|
|
1367
|
+
};
|
|
1368
|
+
if (standardMap[op.useCase]) {
|
|
1369
|
+
return { category: 'standard', variant: standardMap[op.useCase] };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const rootEntity = aggregate.rootEntity;
|
|
1373
|
+
const enums = aggregate.enums || [];
|
|
1374
|
+
|
|
1375
|
+
// 2. Enum transitions — pattern: {MethodPascal}{Aggregate}
|
|
1376
|
+
for (const enumDef of enums) {
|
|
1377
|
+
for (const transition of (enumDef.transitions || [])) {
|
|
1378
|
+
const methodPascal = toPascalCase(transition.method);
|
|
1379
|
+
if (op.useCase === `${methodPascal}${aggregateName}`) {
|
|
1380
|
+
return {
|
|
1381
|
+
category: 'transition',
|
|
1382
|
+
domainMethod: transition.method,
|
|
1383
|
+
enumName: enumDef.name,
|
|
1384
|
+
targetStatus: Array.isArray(transition.to) ? transition.to[0] : transition.to
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// 3. Sub-entity operations — pattern: Add{Entity} / Remove{Entity} (OneToMany only)
|
|
1391
|
+
const oneToManyRels = (rootEntity.relationships || []).filter(r =>
|
|
1392
|
+
r.type === 'OneToMany' && !r.isInverse
|
|
1393
|
+
);
|
|
1394
|
+
for (const rel of oneToManyRels) {
|
|
1395
|
+
if (op.useCase === `Add${rel.target}`) {
|
|
1396
|
+
const targetEntity = (aggregate.secondaryEntities || []).find(e => e.name === rel.target);
|
|
1397
|
+
const entityFields = targetEntity
|
|
1398
|
+
? targetEntity.fields.filter(f =>
|
|
1399
|
+
f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
|
|
1400
|
+
f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly
|
|
1401
|
+
)
|
|
1402
|
+
: [];
|
|
1403
|
+
return {
|
|
1404
|
+
category: 'subEntityAdd',
|
|
1405
|
+
entityName: rel.target,
|
|
1406
|
+
fieldName: rel.fieldName,
|
|
1407
|
+
addMethodName: `add${rel.target}`,
|
|
1408
|
+
entityFields,
|
|
1409
|
+
entityImports: targetEntity ? (targetEntity.imports || []) : []
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
if (op.useCase === `Remove${rel.target}`) {
|
|
1413
|
+
return {
|
|
1414
|
+
category: 'subEntityRemove',
|
|
1415
|
+
entityName: rel.target,
|
|
1416
|
+
fieldName: rel.fieldName,
|
|
1417
|
+
removeMethodName: `remove${rel.target}ById`
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// 4. FindBy field — pattern: FindAll{Aggregate}sBy{FieldPascal}
|
|
1423
|
+
for (const field of (rootEntity.fields || [])) {
|
|
1424
|
+
const fieldPascal = toPascalCase(field.name);
|
|
1425
|
+
const aggregateNamePlural = pluralizeWord(aggregateName);
|
|
1426
|
+
if (op.useCase === `FindAll${aggregateNamePlural}By${fieldPascal}`) {
|
|
1427
|
+
return {
|
|
1428
|
+
category: 'findBy',
|
|
1429
|
+
fieldName: field.name,
|
|
1430
|
+
fieldPascal,
|
|
1431
|
+
fieldJavaType: field.javaType,
|
|
1432
|
+
jpaMethodName: `findBy${fieldPascal}`
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
return { category: 'scaffold' };
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Enrich a single endpoint operation with derived properties for template rendering.
|
|
1442
|
+
* Expects op._classification to be set by classifyUseCase() before this call.
|
|
1443
|
+
*/
|
|
1444
|
+
function enrichEndpointOperation(op, aggregateName, idType) {
|
|
1445
|
+
const httpAnnotationMap = {
|
|
1446
|
+
GET: 'GetMapping', POST: 'PostMapping',
|
|
1447
|
+
PUT: 'PutMapping', PATCH: 'PatchMapping', DELETE: 'DeleteMapping'
|
|
1448
|
+
};
|
|
1449
|
+
const hasPathVar = Boolean(op.path && op.path.includes('{'));
|
|
1450
|
+
const pathVarMatch = hasPathVar ? op.path.match(/\{([^}]+)\}/) : null;
|
|
1451
|
+
const pathVarName = pathVarMatch ? pathVarMatch[1] : 'id';
|
|
1452
|
+
|
|
1453
|
+
const cl = op._classification || { category: 'scaffold' };
|
|
1454
|
+
const isStandard = cl.category === 'standard';
|
|
1455
|
+
const standardType = isStandard ? cl.variant : null;
|
|
1456
|
+
|
|
1457
|
+
// Infer type from HTTP method when not explicitly declared: GET → query, everything else → command
|
|
1458
|
+
const resolvedType = op.type || (op.method === 'GET' ? 'query' : 'command');
|
|
1459
|
+
|
|
1460
|
+
let returnType = 'void';
|
|
1461
|
+
if (standardType === 'getById') returnType = `${aggregateName}ResponseDto`;
|
|
1462
|
+
else if (standardType === 'findAll') returnType = `PagedResponse<${aggregateName}ResponseDto>`;
|
|
1463
|
+
else if (cl.category === 'findBy') returnType = `PagedResponse<${aggregateName}ResponseDto>`;
|
|
1464
|
+
else if (cl.category === 'scaffold' && resolvedType === 'query') returnType = `${aggregateName}ResponseDto`;
|
|
1465
|
+
|
|
1466
|
+
let httpStatus = 'HttpStatus.OK';
|
|
1467
|
+
if (standardType === 'create') httpStatus = 'HttpStatus.CREATED';
|
|
1468
|
+
else if (standardType === 'update') httpStatus = 'HttpStatus.NO_CONTENT';
|
|
1469
|
+
else if (cl.category === 'transition') httpStatus = 'HttpStatus.NO_CONTENT';
|
|
1470
|
+
else if (cl.category === 'subEntityAdd') httpStatus = 'HttpStatus.CREATED';
|
|
1471
|
+
else if (cl.category === 'subEntityRemove') httpStatus = 'HttpStatus.NO_CONTENT';
|
|
1472
|
+
|
|
1473
|
+
return {
|
|
1474
|
+
...op,
|
|
1475
|
+
type: resolvedType,
|
|
1476
|
+
httpAnnotation: httpAnnotationMap[op.method] || 'PostMapping',
|
|
1477
|
+
methodName: toCamelCase(op.useCase),
|
|
1478
|
+
hasPathVar,
|
|
1479
|
+
pathVarName,
|
|
1480
|
+
isStandard,
|
|
1481
|
+
standardType,
|
|
1482
|
+
classifiedType: cl.category,
|
|
1483
|
+
classification: cl,
|
|
1484
|
+
returnType,
|
|
1485
|
+
httpStatus,
|
|
1486
|
+
idType
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Generate endpoint-driven resources (use cases + versioned controllers)
|
|
1492
|
+
* for an aggregate when domain.yaml declares an `endpoints:` section.
|
|
1493
|
+
*/
|
|
1494
|
+
async function generateEndpointsResources(aggregate, endpoints, moduleName, moduleBasePath, packageName, generatedFiles, writeOptions = {}, sharedGeneratedUseCases = null) {
|
|
1495
|
+
const { name: aggregateName, rootEntity, secondaryEntities, valueObjects = [] } = aggregate;
|
|
1496
|
+
const aggregateNamePlural = pluralizeWord(aggregateName);
|
|
1497
|
+
const templatesDir = path.join(__dirname, '..', '..', 'templates', 'crud');
|
|
1498
|
+
|
|
1499
|
+
const idField = rootEntity.fields[0];
|
|
1500
|
+
const idType = idField.javaType;
|
|
1501
|
+
|
|
1502
|
+
const commandFields = rootEntity.fields.filter(f =>
|
|
1503
|
+
f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
|
|
1504
|
+
f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.readOnly
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
const validatedVos = valueObjects.filter(vo =>
|
|
1508
|
+
vo.fields.some(f => f.validationAnnotations && f.validationAnnotations.length > 0)
|
|
1509
|
+
);
|
|
1510
|
+
const validatedVoNames = new Set(validatedVos.map(vo => vo.name));
|
|
1511
|
+
|
|
1512
|
+
const oneToManyRelationships = enrichRelationshipsRecursively(rootEntity, secondaryEntities, 0, new Set());
|
|
1513
|
+
const oneToOneRels = rootEntity.relationships?.filter(r => r.type === 'OneToOne' && !r.isInverse) || [];
|
|
1514
|
+
const oneToOneRelationships = oneToOneRels.map(rel => {
|
|
1515
|
+
const targetEntity = secondaryEntities.find(e => e.name === rel.target);
|
|
1516
|
+
if (!targetEntity) return { targetEntityName: rel.target, fieldName: rel.fieldName, type: rel.type, fields: [] };
|
|
1517
|
+
const targetFields = targetEntity.fields.filter(f =>
|
|
1518
|
+
f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
|
|
1519
|
+
f.name !== 'createdBy' && f.name !== 'updatedBy'
|
|
1520
|
+
);
|
|
1521
|
+
return { targetEntityName: rel.target, fieldName: rel.fieldName, type: rel.type, fields: targetFields, entity: targetEntity };
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
const hasValueObjects = rootEntity.fields.some(f => f.isValueObject);
|
|
1525
|
+
const hasEnums = rootEntity.enums && rootEntity.enums.length > 0;
|
|
1526
|
+
const resourceNameCamel = toCamelCase(aggregateName);
|
|
1527
|
+
const resourceNameKebab = toKebabCase(aggregateName);
|
|
1528
|
+
|
|
1529
|
+
const responseFields = rootEntity.fields.filter(f =>
|
|
1530
|
+
f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.hidden
|
|
1531
|
+
);
|
|
1532
|
+
const responseSecondaryEntities = secondaryEntities.map(entity => ({
|
|
1533
|
+
...entity,
|
|
1534
|
+
responseFields: entity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
|
|
1535
|
+
nestedRelationships: enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set()),
|
|
1536
|
+
forwardOneToOneRels: (entity.relationships || [])
|
|
1537
|
+
.filter(r => r.type === 'OneToOne' && !r.isInverse)
|
|
1538
|
+
.map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }))
|
|
1539
|
+
}));
|
|
1540
|
+
|
|
1541
|
+
// Schema examples for Swagger UI (same heuristics as Postman generator)
|
|
1542
|
+
const localAllEnums = [...(aggregate.enums || []), ...(rootEntity.enums || [])];
|
|
1543
|
+
initSeed(42);
|
|
1544
|
+
|
|
1545
|
+
const commandFieldsApp = enrichFieldsWithSchemaExamples(
|
|
1546
|
+
transformFieldsForApp(commandFields, validatedVoNames), localAllEnums, valueObjects);
|
|
1547
|
+
const oneToOneRelationshipsApp = oneToOneRelationships.map(rel => ({
|
|
1548
|
+
...rel,
|
|
1549
|
+
fields: enrichFieldsWithSchemaExamples(
|
|
1550
|
+
transformFieldsForApp(rel.fields || [], validatedVoNames), localAllEnums, valueObjects)
|
|
1551
|
+
}));
|
|
1552
|
+
const oneToManyRelationshipsApp = enrichRelsWithSchemaExamples(
|
|
1553
|
+
transformRelsForApp(oneToManyRelationships, validatedVoNames), localAllEnums, valueObjects);
|
|
1554
|
+
|
|
1555
|
+
const baseContext = {
|
|
1556
|
+
packageName, moduleName, aggregateName, aggregateNamePlural, rootEntity, secondaryEntities,
|
|
1557
|
+
responseFields, responseSecondaryEntities, idType,
|
|
1558
|
+
commandFields: commandFieldsApp, oneToManyRelationships, oneToOneRelationships,
|
|
1559
|
+
hasValueObjects, hasEnums, imports: rootEntity.imports,
|
|
1560
|
+
resourceNameCamel, resourceNameKebab,
|
|
1561
|
+
hasSoftDelete: rootEntity.hasSoftDelete || false
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
// ── Step 1: Validated VO Dtos ────────────────────────────────────────
|
|
1565
|
+
for (const vo of validatedVos) {
|
|
1566
|
+
const voDtoContext = {
|
|
1567
|
+
packageName, moduleName, voName: vo.name,
|
|
1568
|
+
fields: enrichFieldsWithSchemaExamples(vo.fields, localAllEnums, valueObjects),
|
|
1569
|
+
hasEnums: (vo.imports || []).some(i => i.includes('.enums.')),
|
|
1570
|
+
imports: [...(vo.imports || []), ...generateValidationImports(vo.fields)]
|
|
1571
|
+
};
|
|
1572
|
+
await renderAndWrite(
|
|
1573
|
+
path.join(templatesDir, 'CreateValueObjectDto.java.ejs'),
|
|
1574
|
+
path.join(moduleBasePath, 'application', 'dtos', `Create${vo.name}Dto.java`),
|
|
1575
|
+
voDtoContext, writeOptions
|
|
1576
|
+
);
|
|
1577
|
+
generatedFiles.push({ type: 'DTO', name: `Create${vo.name}Dto`, path: `${moduleName}/application/dtos/Create${vo.name}Dto.java` });
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// ── Step 2: ApplicationMapper ────────────────────────────────────────
|
|
1581
|
+
// Only emit Create{Aggregate}Command import and fromCommand() when a
|
|
1582
|
+
// standard CreateOrder operation is declared — other POST use cases
|
|
1583
|
+
// (e.g. PlaceOrder) are scaffolds and never produce that command class.
|
|
1584
|
+
const hasCreateOperation = endpoints.versions.some(v =>
|
|
1585
|
+
v.operations.some(op => op.useCase === `Create${aggregateName}`)
|
|
1586
|
+
);
|
|
1587
|
+
await renderAndWrite(
|
|
1588
|
+
path.join(templatesDir, 'ApplicationMapper.java.ejs'),
|
|
1589
|
+
path.join(moduleBasePath, 'application', 'mappers', `${aggregateName}ApplicationMapper.java`),
|
|
1590
|
+
{ ...baseContext, commandFields: commandFieldsApp, oneToOneRelationships: oneToOneRelationshipsApp, oneToManyRelationships: oneToManyRelationshipsApp, validatedVos, hasCreateOperation },
|
|
1591
|
+
writeOptions
|
|
1592
|
+
);
|
|
1593
|
+
generatedFiles.push({ type: 'Application Mapper', name: `${aggregateName}ApplicationMapper`, path: `${moduleName}/application/mappers/${aggregateName}ApplicationMapper.java` });
|
|
1594
|
+
|
|
1595
|
+
// ── Step 3: ResponseDto ──────────────────────────────────────────────
|
|
1596
|
+
const responseDtoContext = {
|
|
1597
|
+
...baseContext,
|
|
1598
|
+
allFields: rootEntity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.hidden),
|
|
1599
|
+
relationships: rootEntity.relationships.filter(r => (r.type === 'OneToMany' || r.type === 'OneToOne') && !r.isInverse)
|
|
1600
|
+
};
|
|
1601
|
+
await renderAndWrite(
|
|
1602
|
+
path.join(templatesDir, 'ResponseDto.java.ejs'),
|
|
1603
|
+
path.join(moduleBasePath, 'application', 'dtos', `${aggregateName}ResponseDto.java`),
|
|
1604
|
+
responseDtoContext, writeOptions
|
|
1605
|
+
);
|
|
1606
|
+
generatedFiles.push({ type: 'DTO', name: `${aggregateName}ResponseDto`, path: `${moduleName}/application/dtos/${aggregateName}ResponseDto.java` });
|
|
1607
|
+
|
|
1608
|
+
// ── Step 4: Secondary entity DTOs ───────────────────────────────────
|
|
1609
|
+
for (const entity of secondaryEntities) {
|
|
1610
|
+
const nestedRelationships = enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set());
|
|
1611
|
+
const forwardOoO = (entity.relationships || []).filter(r => r.type === 'OneToOne' && !r.isInverse)
|
|
1612
|
+
.map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }));
|
|
1613
|
+
|
|
1614
|
+
await renderAndWrite(
|
|
1615
|
+
path.join(templatesDir, 'SecondaryEntityDto.java.ejs'),
|
|
1616
|
+
path.join(moduleBasePath, 'application', 'dtos', `${entity.name}Dto.java`),
|
|
1617
|
+
{ packageName, moduleName, entityName: entity.name,
|
|
1618
|
+
fields: entity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
|
|
1619
|
+
nestedRelationships, hasNestedRelationships: nestedRelationships.length > 0,
|
|
1620
|
+
forwardOneToOneRels: forwardOoO,
|
|
1621
|
+
hasValueObjects: entity.fields.some(f => f.isValueObject),
|
|
1622
|
+
hasEnums: entity.enums && entity.enums.length > 0, imports: entity.imports },
|
|
1623
|
+
writeOptions
|
|
1624
|
+
);
|
|
1625
|
+
generatedFiles.push({ type: 'DTO', name: `${entity.name}Dto`, path: `${moduleName}/application/dtos/${entity.name}Dto.java` });
|
|
1626
|
+
|
|
1627
|
+
const createFields = entity.fields.filter(f =>
|
|
1628
|
+
f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
|
|
1629
|
+
f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly
|
|
1630
|
+
);
|
|
1631
|
+
const entityNestedRels = enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set());
|
|
1632
|
+
const createFieldsApp = enrichFieldsWithSchemaExamples(
|
|
1633
|
+
transformFieldsForApp(createFields, validatedVoNames), localAllEnums, valueObjects);
|
|
1634
|
+
const dtoVoDtoImports = validatedVos.filter(vo => createFieldsApp.some(f => f.originalVoType === vo.name))
|
|
1635
|
+
.map(vo => `import ${packageName}.${moduleName}.application.dtos.Create${vo.name}Dto;`);
|
|
1636
|
+
const createDtoImports = [...new Set([
|
|
1637
|
+
...(entity.imports || []), ...generateValidationImports(createFieldsApp), ...dtoVoDtoImports,
|
|
1638
|
+
...(createFieldsApp.some(f => f.originalVoType) ? ['import jakarta.validation.Valid;'] : [])
|
|
1639
|
+
])];
|
|
1640
|
+
const fwdOoO = (entity.relationships || []).filter(r => r.type === 'OneToOne' && !r.isInverse)
|
|
1641
|
+
.map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }));
|
|
1642
|
+
|
|
1643
|
+
await renderAndWrite(
|
|
1644
|
+
path.join(templatesDir, 'CreateItemDto.java.ejs'),
|
|
1645
|
+
path.join(moduleBasePath, 'application', 'dtos', `Create${entity.name}Dto.java`),
|
|
1646
|
+
{ packageName, moduleName, entityName: entity.name, fields: createFieldsApp,
|
|
1647
|
+
nestedRelationships: entityNestedRels, hasNestedRelationships: entityNestedRels.length > 0,
|
|
1648
|
+
forwardOneToOneRels: fwdOoO,
|
|
1649
|
+
hasValueObjects: entity.fields.some(f => f.isValueObject),
|
|
1650
|
+
hasEnums: entity.enums && entity.enums.length > 0, imports: createDtoImports },
|
|
1651
|
+
writeOptions
|
|
1652
|
+
);
|
|
1653
|
+
generatedFiles.push({ type: 'DTO', name: `Create${entity.name}Dto`, path: `${moduleName}/application/dtos/Create${entity.name}Dto.java` });
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// ── Step 5: Generate declared use cases (anti-duplicate across versions) ─
|
|
1657
|
+
const commandVoDtoImports = validatedVos
|
|
1658
|
+
.filter(vo => commandFieldsApp.some(f => f.originalVoType === vo.name))
|
|
1659
|
+
.map(vo => `import ${packageName}.${moduleName}.application.dtos.Create${vo.name}Dto;`);
|
|
1660
|
+
const commandAppImports = [...new Set([
|
|
1661
|
+
...(rootEntity.imports || []), ...generateValidationImports(commandFieldsApp), ...commandVoDtoImports,
|
|
1662
|
+
...(commandFieldsApp.some(f => f.originalVoType) ? ['import jakarta.validation.Valid;'] : [])
|
|
1663
|
+
])];
|
|
1664
|
+
|
|
1665
|
+
// Defensive: classify ops not yet assigned by the outer pre-pass
|
|
1666
|
+
// (single-aggregate modules or direct calls without a shared set).
|
|
1667
|
+
for (const version of endpoints.versions) {
|
|
1668
|
+
for (const op of version.operations) {
|
|
1669
|
+
if (!op._classification) {
|
|
1670
|
+
op._classification = classifyUseCase(op, aggregateName, aggregate);
|
|
1671
|
+
op._ownerAggregate = op._ownerAggregate || aggregateName;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
const generatedUseCases = sharedGeneratedUseCases ?? new Set();
|
|
1677
|
+
const findByOps = []; // collect FindBy ops for repository re-generation
|
|
1678
|
+
|
|
1679
|
+
for (const version of endpoints.versions) {
|
|
1680
|
+
for (const op of version.operations) {
|
|
1681
|
+
// Skip operations owned by a different aggregate (multi-aggregate endpoints section)
|
|
1682
|
+
// MUST come before the shared-set check so foreign ops don't poison the set.
|
|
1683
|
+
if (op._ownerAggregate !== aggregateName) continue;
|
|
1684
|
+
|
|
1685
|
+
if (generatedUseCases.has(op.useCase)) continue; // anti-duplicate (same use case across versions)
|
|
1686
|
+
generatedUseCases.add(op.useCase);
|
|
1687
|
+
|
|
1688
|
+
const cl = op._classification;
|
|
1689
|
+
|
|
1690
|
+
if (cl.category === 'standard') {
|
|
1691
|
+
const isStandard = true;
|
|
1692
|
+
if (cl.variant === 'create') {
|
|
1693
|
+
await renderAndWrite(
|
|
1694
|
+
path.join(templatesDir, 'CreateCommand.java.ejs'),
|
|
1695
|
+
path.join(moduleBasePath, 'application', 'commands', `Create${aggregateName}Command.java`),
|
|
1696
|
+
{ ...baseContext, imports: commandAppImports }, writeOptions
|
|
1697
|
+
);
|
|
1698
|
+
generatedFiles.push({ type: 'Command', name: `Create${aggregateName}Command`, path: `${moduleName}/application/commands/Create${aggregateName}Command.java` });
|
|
1699
|
+
|
|
1700
|
+
await renderAndWrite(
|
|
1701
|
+
path.join(templatesDir, 'CreateCommandHandler.java.ejs'),
|
|
1702
|
+
path.join(moduleBasePath, 'application', 'usecases', `Create${aggregateName}CommandHandler.java`),
|
|
1703
|
+
{ ...baseContext, commandFields: commandFieldsApp, oneToOneRelationships: oneToOneRelationshipsApp, oneToManyRelationships: oneToManyRelationshipsApp, validatedVos }, writeOptions
|
|
1704
|
+
);
|
|
1705
|
+
generatedFiles.push({ type: 'Handler', name: `Create${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Create${aggregateName}CommandHandler.java` });
|
|
1706
|
+
|
|
1707
|
+
} else if (cl.variant === 'update') {
|
|
1708
|
+
await renderAndWrite(
|
|
1709
|
+
path.join(templatesDir, 'UpdateCommand.java.ejs'),
|
|
1710
|
+
path.join(moduleBasePath, 'application', 'commands', `Update${aggregateName}Command.java`),
|
|
1711
|
+
{ ...baseContext, imports: commandAppImports }, writeOptions
|
|
1712
|
+
);
|
|
1713
|
+
generatedFiles.push({ type: 'Command', name: `Update${aggregateName}Command`, path: `${moduleName}/application/commands/Update${aggregateName}Command.java` });
|
|
1714
|
+
|
|
1715
|
+
await renderAndWrite(
|
|
1716
|
+
path.join(templatesDir, 'UpdateCommandHandler.java.ejs'),
|
|
1717
|
+
path.join(moduleBasePath, 'application', 'usecases', `Update${aggregateName}CommandHandler.java`),
|
|
1718
|
+
baseContext, writeOptions
|
|
1719
|
+
);
|
|
1720
|
+
generatedFiles.push({ type: 'Handler', name: `Update${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Update${aggregateName}CommandHandler.java` });
|
|
1721
|
+
|
|
1722
|
+
} else if (cl.variant === 'delete') {
|
|
1723
|
+
await renderAndWrite(
|
|
1724
|
+
path.join(templatesDir, 'DeleteCommand.java.ejs'),
|
|
1725
|
+
path.join(moduleBasePath, 'application', 'commands', `Delete${aggregateName}Command.java`),
|
|
1726
|
+
baseContext, writeOptions
|
|
1727
|
+
);
|
|
1728
|
+
generatedFiles.push({ type: 'Command', name: `Delete${aggregateName}Command`, path: `${moduleName}/application/commands/Delete${aggregateName}Command.java` });
|
|
1729
|
+
|
|
1730
|
+
await renderAndWrite(
|
|
1731
|
+
path.join(templatesDir, 'DeleteCommandHandler.java.ejs'),
|
|
1732
|
+
path.join(moduleBasePath, 'application', 'usecases', `Delete${aggregateName}CommandHandler.java`),
|
|
1733
|
+
baseContext, writeOptions
|
|
1734
|
+
);
|
|
1735
|
+
generatedFiles.push({ type: 'Handler', name: `Delete${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Delete${aggregateName}CommandHandler.java` });
|
|
1736
|
+
|
|
1737
|
+
} else if (cl.variant === 'getById') {
|
|
1738
|
+
await renderAndWrite(
|
|
1739
|
+
path.join(templatesDir, 'GetQuery.java.ejs'),
|
|
1740
|
+
path.join(moduleBasePath, 'application', 'queries', `Get${aggregateName}Query.java`),
|
|
1741
|
+
baseContext, writeOptions
|
|
1742
|
+
);
|
|
1743
|
+
generatedFiles.push({ type: 'Query', name: `Get${aggregateName}Query`, path: `${moduleName}/application/queries/Get${aggregateName}Query.java` });
|
|
1744
|
+
|
|
1745
|
+
await renderAndWrite(
|
|
1746
|
+
path.join(templatesDir, 'GetQueryHandler.java.ejs'),
|
|
1747
|
+
path.join(moduleBasePath, 'application', 'usecases', `Get${aggregateName}QueryHandler.java`),
|
|
1748
|
+
baseContext, writeOptions
|
|
1749
|
+
);
|
|
1750
|
+
generatedFiles.push({ type: 'Handler', name: `Get${aggregateName}QueryHandler`, path: `${moduleName}/application/usecases/Get${aggregateName}QueryHandler.java` });
|
|
1751
|
+
|
|
1752
|
+
} else if (cl.variant === 'findAll') {
|
|
1753
|
+
await renderAndWrite(
|
|
1754
|
+
path.join(templatesDir, 'ListQuery.java.ejs'),
|
|
1755
|
+
path.join(moduleBasePath, 'application', 'queries', `FindAll${aggregateNamePlural}Query.java`),
|
|
1756
|
+
baseContext, writeOptions
|
|
1757
|
+
);
|
|
1758
|
+
generatedFiles.push({ type: 'Query', name: `FindAll${aggregateNamePlural}Query`, path: `${moduleName}/application/queries/FindAll${aggregateNamePlural}Query.java` });
|
|
1759
|
+
|
|
1760
|
+
await renderAndWrite(
|
|
1761
|
+
path.join(templatesDir, 'ListQueryHandler.java.ejs'),
|
|
1762
|
+
path.join(moduleBasePath, 'application', 'usecases', `FindAll${aggregateNamePlural}QueryHandler.java`),
|
|
1763
|
+
baseContext, writeOptions
|
|
1764
|
+
);
|
|
1765
|
+
generatedFiles.push({ type: 'Handler', name: `FindAll${aggregateNamePlural}QueryHandler`, path: `${moduleName}/application/usecases/FindAll${aggregateNamePlural}QueryHandler.java` });
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
} else if (cl.category === 'transition') {
|
|
1769
|
+
// Transition: {MethodPascal}{Aggregate} → findById → entity.{method}() → save
|
|
1770
|
+
const transitionContext = {
|
|
1771
|
+
packageName, moduleName, aggregateName,
|
|
1772
|
+
useCaseName: op.useCase,
|
|
1773
|
+
idType,
|
|
1774
|
+
domainMethod: cl.domainMethod
|
|
1775
|
+
};
|
|
1776
|
+
await renderAndWrite(
|
|
1777
|
+
path.join(templatesDir, 'TransitionCommand.java.ejs'),
|
|
1778
|
+
path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
|
|
1779
|
+
transitionContext, writeOptions
|
|
1780
|
+
);
|
|
1781
|
+
generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
|
|
1782
|
+
|
|
1783
|
+
await renderAndWrite(
|
|
1784
|
+
path.join(templatesDir, 'TransitionCommandHandler.java.ejs'),
|
|
1785
|
+
path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
|
|
1786
|
+
transitionContext, writeOptions
|
|
1787
|
+
);
|
|
1788
|
+
generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
|
|
1789
|
+
|
|
1790
|
+
} else if (cl.category === 'subEntityAdd') {
|
|
1791
|
+
// SubEntityAdd: Add{EntityName} → findById → entity.add{Entity}(...) → save
|
|
1792
|
+
const addContext = {
|
|
1793
|
+
packageName, moduleName, aggregateName,
|
|
1794
|
+
useCaseName: op.useCase,
|
|
1795
|
+
idType,
|
|
1796
|
+
entityName: cl.entityName,
|
|
1797
|
+
entityFields: enrichFieldsWithSchemaExamples(cl.entityFields || [], localAllEnums, valueObjects),
|
|
1798
|
+
addMethodName: cl.addMethodName,
|
|
1799
|
+
imports: cl.entityImports
|
|
1800
|
+
};
|
|
1801
|
+
await renderAndWrite(
|
|
1802
|
+
path.join(templatesDir, 'SubEntityAddCommand.java.ejs'),
|
|
1803
|
+
path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
|
|
1804
|
+
addContext, writeOptions
|
|
1805
|
+
);
|
|
1806
|
+
generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
|
|
1807
|
+
|
|
1808
|
+
await renderAndWrite(
|
|
1809
|
+
path.join(templatesDir, 'SubEntityAddCommandHandler.java.ejs'),
|
|
1810
|
+
path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
|
|
1811
|
+
addContext, writeOptions
|
|
1812
|
+
);
|
|
1813
|
+
generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
|
|
1814
|
+
|
|
1815
|
+
} else if (cl.category === 'subEntityRemove') {
|
|
1816
|
+
// SubEntityRemove: Remove{EntityName} → findById → entity.remove{Entity}ById(itemId) → save
|
|
1817
|
+
const removeContext = {
|
|
1818
|
+
packageName, moduleName, aggregateName,
|
|
1819
|
+
useCaseName: op.useCase,
|
|
1820
|
+
idType,
|
|
1821
|
+
entityName: cl.entityName,
|
|
1822
|
+
removeMethodName: cl.removeMethodName
|
|
1823
|
+
};
|
|
1824
|
+
await renderAndWrite(
|
|
1825
|
+
path.join(templatesDir, 'SubEntityRemoveCommand.java.ejs'),
|
|
1826
|
+
path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
|
|
1827
|
+
removeContext, writeOptions
|
|
1828
|
+
);
|
|
1829
|
+
generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
|
|
1830
|
+
|
|
1831
|
+
await renderAndWrite(
|
|
1832
|
+
path.join(templatesDir, 'SubEntityRemoveCommandHandler.java.ejs'),
|
|
1833
|
+
path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
|
|
1834
|
+
removeContext, writeOptions
|
|
1835
|
+
);
|
|
1836
|
+
generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
|
|
1837
|
+
|
|
1838
|
+
} else if (cl.category === 'findBy') {
|
|
1839
|
+
// FindBy: FindAll{Aggregate}sBy{Field} → paginated query on a root field
|
|
1840
|
+
findByOps.push(cl); // collected for repository re-generation after the loop
|
|
1841
|
+
const findByContext = {
|
|
1842
|
+
packageName, moduleName, aggregateName,
|
|
1843
|
+
useCaseName: op.useCase,
|
|
1844
|
+
idType,
|
|
1845
|
+
fieldName: cl.fieldName,
|
|
1846
|
+
fieldPascal: cl.fieldPascal,
|
|
1847
|
+
fieldJavaType: cl.fieldJavaType,
|
|
1848
|
+
jpaMethodName: cl.jpaMethodName
|
|
1849
|
+
};
|
|
1850
|
+
await renderAndWrite(
|
|
1851
|
+
path.join(templatesDir, 'FindByQuery.java.ejs'),
|
|
1852
|
+
path.join(moduleBasePath, 'application', 'queries', `${op.useCase}Query.java`),
|
|
1853
|
+
findByContext, writeOptions
|
|
1854
|
+
);
|
|
1855
|
+
generatedFiles.push({ type: 'Query', name: `${op.useCase}Query`, path: `${moduleName}/application/queries/${op.useCase}Query.java` });
|
|
1856
|
+
|
|
1857
|
+
await renderAndWrite(
|
|
1858
|
+
path.join(templatesDir, 'FindByQueryHandler.java.ejs'),
|
|
1859
|
+
path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}QueryHandler.java`),
|
|
1860
|
+
findByContext, writeOptions
|
|
1861
|
+
);
|
|
1862
|
+
generatedFiles.push({ type: 'Handler', name: `${op.useCase}QueryHandler`, path: `${moduleName}/application/usecases/${op.useCase}QueryHandler.java` });
|
|
1863
|
+
|
|
1864
|
+
} else {
|
|
1865
|
+
// Scaffold: no semantic pattern matched → generate stub with TODO
|
|
1866
|
+
const scaffoldContext = { packageName, moduleName, aggregateName, useCaseName: op.useCase };
|
|
1867
|
+
const scaffoldType = op.type || (op.method === 'GET' ? 'query' : 'command');
|
|
1868
|
+
if (scaffoldType === 'command') {
|
|
1869
|
+
await renderAndWrite(
|
|
1870
|
+
path.join(templatesDir, 'ScaffoldCommand.java.ejs'),
|
|
1871
|
+
path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
|
|
1872
|
+
scaffoldContext, writeOptions
|
|
1873
|
+
);
|
|
1874
|
+
generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
|
|
1875
|
+
|
|
1876
|
+
await renderAndWrite(
|
|
1877
|
+
path.join(templatesDir, 'ScaffoldCommandHandler.java.ejs'),
|
|
1878
|
+
path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
|
|
1879
|
+
scaffoldContext, writeOptions
|
|
1880
|
+
);
|
|
1881
|
+
generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
|
|
1882
|
+
} else {
|
|
1883
|
+
await renderAndWrite(
|
|
1884
|
+
path.join(templatesDir, 'ScaffoldQuery.java.ejs'),
|
|
1885
|
+
path.join(moduleBasePath, 'application', 'queries', `${op.useCase}Query.java`),
|
|
1886
|
+
scaffoldContext, writeOptions
|
|
1887
|
+
);
|
|
1888
|
+
generatedFiles.push({ type: 'Query', name: `${op.useCase}Query`, path: `${moduleName}/application/queries/${op.useCase}Query.java` });
|
|
1889
|
+
|
|
1890
|
+
await renderAndWrite(
|
|
1891
|
+
path.join(templatesDir, 'ScaffoldQueryHandler.java.ejs'),
|
|
1892
|
+
path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}QueryHandler.java`),
|
|
1893
|
+
scaffoldContext, writeOptions
|
|
1894
|
+
);
|
|
1895
|
+
generatedFiles.push({ type: 'Handler', name: `${op.useCase}QueryHandler`, path: `${moduleName}/application/usecases/${op.useCase}QueryHandler.java` });
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// ── Step 5b: Re-generate repository files when FindBy ops are present ────
|
|
1902
|
+
// Checksum protection still applies: manually modified files are skipped.
|
|
1903
|
+
if (findByOps.length > 0) {
|
|
1904
|
+
const aggregateTemplatesDir = path.join(__dirname, '..', '..', 'templates', 'aggregate');
|
|
1905
|
+
const repoContext = { packageName, moduleName, rootEntity, findByOps };
|
|
1906
|
+
const repoImplContext = {
|
|
1907
|
+
packageName, moduleName, aggregateName, rootEntity,
|
|
1908
|
+
hasDomainEvents: (aggregate.domainEvents || []).length > 0,
|
|
1909
|
+
findByOps
|
|
1910
|
+
};
|
|
1911
|
+
await renderAndWrite(
|
|
1912
|
+
path.join(aggregateTemplatesDir, 'AggregateRepository.java.ejs'),
|
|
1913
|
+
path.join(moduleBasePath, 'domain', 'repositories', `${rootEntity.name}Repository.java`),
|
|
1914
|
+
repoContext, writeOptions
|
|
1915
|
+
);
|
|
1916
|
+
await renderAndWrite(
|
|
1917
|
+
path.join(aggregateTemplatesDir, 'JpaRepository.java.ejs'),
|
|
1918
|
+
path.join(moduleBasePath, 'infrastructure', 'database', 'repositories', `${rootEntity.name}JpaRepository.java`),
|
|
1919
|
+
repoContext, writeOptions
|
|
1920
|
+
);
|
|
1921
|
+
await renderAndWrite(
|
|
1922
|
+
path.join(aggregateTemplatesDir, 'AggregateRepositoryImpl.java.ejs'),
|
|
1923
|
+
path.join(moduleBasePath, 'infrastructure', 'database', 'repositories', `${rootEntity.name}RepositoryImpl.java`),
|
|
1924
|
+
repoImplContext, writeOptions
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// ── Step 6: Versioned controllers ────────────────────────────────────
|
|
1929
|
+
for (const version of endpoints.versions) {
|
|
1930
|
+
const versionCap = version.version.charAt(0).toUpperCase() + version.version.slice(1);
|
|
1931
|
+
const controllerName = `${aggregateName}${versionCap}Controller`;
|
|
1932
|
+
const enrichedOps = version.operations
|
|
1933
|
+
.filter(op => op._ownerAggregate === aggregateName)
|
|
1934
|
+
.map(op => enrichEndpointOperation(op, aggregateName, idType));
|
|
1935
|
+
|
|
1936
|
+
const controllerContext = {
|
|
1937
|
+
...baseContext,
|
|
1938
|
+
apiVersion: version.version,
|
|
1939
|
+
controllerName,
|
|
1940
|
+
operations: enrichedOps,
|
|
1941
|
+
basePath: endpoints.basePath,
|
|
1942
|
+
commandFields: commandFieldsApp,
|
|
1943
|
+
oneToManyRelationships,
|
|
1944
|
+
oneToOneRelationships
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
await renderAndWrite(
|
|
1948
|
+
path.join(templatesDir, 'EndpointsController.java.ejs'),
|
|
1949
|
+
path.join(moduleBasePath, 'infrastructure', 'rest', 'controllers', resourceNameCamel, version.version, `${controllerName}.java`),
|
|
1950
|
+
controllerContext, writeOptions
|
|
1951
|
+
);
|
|
1952
|
+
generatedFiles.push({ type: 'Controller', name: controllerName, path: `${moduleName}/infrastructure/rest/controllers/${resourceNameCamel}/${version.version}/${controllerName}.java` });
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
689
1956
|
/**
|
|
690
1957
|
* Generate CRUD resources for an aggregate root
|
|
691
1958
|
*/
|
|
692
1959
|
async function generateCrudResources(aggregate, moduleName, moduleBasePath, packageName, apiVersion, generatedFiles, writeOptions = {}) {
|
|
693
1960
|
const { name: aggregateName, rootEntity, secondaryEntities, valueObjects = [] } = aggregate;
|
|
1961
|
+
const aggregateNamePlural = pluralizeWord(aggregateName);
|
|
694
1962
|
const templatesDir = path.join(__dirname, '..', '..', 'templates', 'crud');
|
|
695
1963
|
|
|
696
1964
|
// Get ID field and type
|
|
697
1965
|
const idField = rootEntity.fields[0];
|
|
698
1966
|
const idType = idField.javaType;
|
|
699
1967
|
|
|
700
|
-
// Filter command fields (exclude id, audit fields, and readOnly fields)
|
|
1968
|
+
// Filter command fields (exclude id, audit fields, soft-delete fields, and readOnly fields)
|
|
701
1969
|
const commandFields = rootEntity.fields.filter(f =>
|
|
702
|
-
f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
|
|
1970
|
+
f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
|
|
1971
|
+
f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.readOnly
|
|
703
1972
|
);
|
|
704
1973
|
|
|
705
1974
|
// Validated VOs: VOs where any field has validation annotations
|
|
@@ -764,9 +2033,9 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
764
2033
|
const resourceNameCamel = toCamelCase(aggregateName);
|
|
765
2034
|
const resourceNameKebab = toKebabCase(aggregateName);
|
|
766
2035
|
|
|
767
|
-
// Filter audit user fields and hidden fields from response DTOs
|
|
2036
|
+
// Filter audit user fields, soft-delete fields, and hidden fields from response DTOs
|
|
768
2037
|
const responseFields = rootEntity.fields.filter(f =>
|
|
769
|
-
f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden
|
|
2038
|
+
f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.hidden
|
|
770
2039
|
);
|
|
771
2040
|
|
|
772
2041
|
const responseSecondaryEntities = secondaryEntities.map(entity => ({
|
|
@@ -781,18 +2050,26 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
781
2050
|
}));
|
|
782
2051
|
|
|
783
2052
|
// Apply app-layer field transformation (VO fields become Create<Vo>Dto types)
|
|
784
|
-
|
|
2053
|
+
// Schema examples for Swagger UI (same heuristics as Postman generator)
|
|
2054
|
+
const localAllEnums = [...(aggregate.enums || []), ...(rootEntity.enums || [])];
|
|
2055
|
+
initSeed(42);
|
|
2056
|
+
|
|
2057
|
+
const commandFieldsApp = enrichFieldsWithSchemaExamples(
|
|
2058
|
+
transformFieldsForApp(commandFields, validatedVoNames), localAllEnums, valueObjects);
|
|
785
2059
|
const oneToOneRelationshipsApp = oneToOneRelationships.map(rel => ({
|
|
786
2060
|
...rel,
|
|
787
|
-
fields:
|
|
2061
|
+
fields: enrichFieldsWithSchemaExamples(
|
|
2062
|
+
transformFieldsForApp(rel.fields || [], validatedVoNames), localAllEnums, valueObjects)
|
|
788
2063
|
}));
|
|
789
|
-
const oneToManyRelationshipsApp =
|
|
2064
|
+
const oneToManyRelationshipsApp = enrichRelsWithSchemaExamples(
|
|
2065
|
+
transformRelsForApp(oneToManyRelationships, validatedVoNames), localAllEnums, valueObjects);
|
|
790
2066
|
|
|
791
2067
|
// Base context for all templates
|
|
792
2068
|
const baseContext = {
|
|
793
2069
|
packageName,
|
|
794
2070
|
moduleName,
|
|
795
2071
|
aggregateName,
|
|
2072
|
+
aggregateNamePlural,
|
|
796
2073
|
rootEntity,
|
|
797
2074
|
secondaryEntities,
|
|
798
2075
|
responseFields,
|
|
@@ -806,7 +2083,9 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
806
2083
|
imports: rootEntity.imports,
|
|
807
2084
|
apiVersion,
|
|
808
2085
|
resourceNameCamel,
|
|
809
|
-
resourceNameKebab
|
|
2086
|
+
resourceNameKebab,
|
|
2087
|
+
hasSoftDelete: rootEntity.hasSoftDelete || false,
|
|
2088
|
+
hasCreateOperation: true // In interactive CRUD flow, Create is always generated
|
|
810
2089
|
};
|
|
811
2090
|
|
|
812
2091
|
// 0. Generate Create<VoName>Dto for validated Value Objects
|
|
@@ -815,7 +2094,7 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
815
2094
|
packageName,
|
|
816
2095
|
moduleName,
|
|
817
2096
|
voName: vo.name,
|
|
818
|
-
fields: vo.fields,
|
|
2097
|
+
fields: enrichFieldsWithSchemaExamples(vo.fields, localAllEnums, valueObjects),
|
|
819
2098
|
hasEnums: (vo.imports || []).some(i => i.includes('.enums.')),
|
|
820
2099
|
imports: [...(vo.imports || []), ...generateValidationImports(vo.fields)]
|
|
821
2100
|
};
|
|
@@ -837,7 +2116,8 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
837
2116
|
commandFields: commandFieldsApp,
|
|
838
2117
|
oneToOneRelationships: oneToOneRelationshipsApp,
|
|
839
2118
|
oneToManyRelationships: oneToManyRelationshipsApp,
|
|
840
|
-
validatedVos
|
|
2119
|
+
validatedVos,
|
|
2120
|
+
hasCreateOperation: true
|
|
841
2121
|
},
|
|
842
2122
|
writeOptions
|
|
843
2123
|
);
|
|
@@ -888,11 +2168,11 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
888
2168
|
|
|
889
2169
|
await renderAndWrite(
|
|
890
2170
|
path.join(templatesDir, 'ListQuery.java.ejs'),
|
|
891
|
-
path.join(moduleBasePath, 'application', 'queries', `FindAll${
|
|
2171
|
+
path.join(moduleBasePath, 'application', 'queries', `FindAll${aggregateNamePlural}Query.java`),
|
|
892
2172
|
baseContext,
|
|
893
2173
|
writeOptions
|
|
894
2174
|
);
|
|
895
|
-
generatedFiles.push({ type: 'Query', name: `FindAll${
|
|
2175
|
+
generatedFiles.push({ type: 'Query', name: `FindAll${aggregateNamePlural}Query`, path: `${moduleName}/application/queries/FindAll${aggregateNamePlural}Query.java` });
|
|
896
2176
|
|
|
897
2177
|
// 4. Generate Handlers
|
|
898
2178
|
await renderAndWrite(
|
|
@@ -919,11 +2199,11 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
919
2199
|
|
|
920
2200
|
await renderAndWrite(
|
|
921
2201
|
path.join(templatesDir, 'ListQueryHandler.java.ejs'),
|
|
922
|
-
path.join(moduleBasePath, 'application', 'usecases', `FindAll${
|
|
2202
|
+
path.join(moduleBasePath, 'application', 'usecases', `FindAll${aggregateNamePlural}QueryHandler.java`),
|
|
923
2203
|
baseContext,
|
|
924
2204
|
writeOptions
|
|
925
2205
|
);
|
|
926
|
-
generatedFiles.push({ type: 'Handler', name: `FindAll${
|
|
2206
|
+
generatedFiles.push({ type: 'Handler', name: `FindAll${aggregateNamePlural}QueryHandler`, path: `${moduleName}/application/usecases/FindAll${aggregateNamePlural}QueryHandler.java` });
|
|
927
2207
|
|
|
928
2208
|
await renderAndWrite(
|
|
929
2209
|
path.join(templatesDir, 'DeleteCommandHandler.java.ejs'),
|
|
@@ -944,7 +2224,7 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
944
2224
|
// 5. Generate DTOs
|
|
945
2225
|
const responseDtoContext = {
|
|
946
2226
|
...baseContext,
|
|
947
|
-
allFields: rootEntity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
|
|
2227
|
+
allFields: rootEntity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.hidden),
|
|
948
2228
|
relationships: rootEntity.relationships.filter(r => (r.type === 'OneToMany' || r.type === 'OneToOne') && !r.isInverse)
|
|
949
2229
|
};
|
|
950
2230
|
|
|
@@ -1008,7 +2288,8 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
|
|
|
1008
2288
|
new Set()
|
|
1009
2289
|
);
|
|
1010
2290
|
|
|
1011
|
-
const createFieldsApp =
|
|
2291
|
+
const createFieldsApp = enrichFieldsWithSchemaExamples(
|
|
2292
|
+
transformFieldsForApp(createFields, validatedVoNames), localAllEnums, valueObjects);
|
|
1012
2293
|
const dtoVoDtoImports = validatedVos
|
|
1013
2294
|
.filter(vo => createFieldsApp.some(f => f.originalVoType === vo.name))
|
|
1014
2295
|
.map(vo => `import ${packageName}.${moduleName}.application.dtos.Create${vo.name}Dto;`);
|
|
@@ -1099,6 +2380,7 @@ async function generatePostmanCollection(
|
|
|
1099
2380
|
|
|
1100
2381
|
const context = {
|
|
1101
2382
|
aggregateName,
|
|
2383
|
+
aggregateNamePlural: pluralizeWord(aggregateName),
|
|
1102
2384
|
moduleName,
|
|
1103
2385
|
resourceNameKebab,
|
|
1104
2386
|
apiVersion,
|