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
|
@@ -151,7 +151,7 @@ async function generateKafkaEventCommand(moduleName, eventName) {
|
|
|
151
151
|
try {
|
|
152
152
|
for (const name of eventNames) {
|
|
153
153
|
const normalizedName = toPascalCase(name);
|
|
154
|
-
const evtClassName = normalizedName.endsWith('
|
|
154
|
+
const evtClassName = normalizedName.endsWith('IntegrationEvent') ? normalizedName : `${normalizedName}IntegrationEvent`;
|
|
155
155
|
|
|
156
156
|
// In batch mode skip already-existing events; in single mode abort
|
|
157
157
|
const evtPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'application', 'events', `${evtClassName}.java`);
|
|
@@ -166,9 +166,9 @@ async function generateKafkaEventCommand(moduleName, eventName) {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
const topicNameKebab = toKebabCase(name);
|
|
170
|
-
const topicNameCamel = toCamelCase(name);
|
|
171
|
-
const topicNameSnake = toSnakeCase(name).toUpperCase();
|
|
169
|
+
const topicNameKebab = toKebabCase(stripEventSuffix(name));
|
|
170
|
+
const topicNameCamel = toCamelCase(stripEventSuffix(name));
|
|
171
|
+
const topicNameSnake = toSnakeCase(stripEventSuffix(name)).toUpperCase();
|
|
172
172
|
const topicSpringProperty = `\${topics.${topicNameKebab}}`;
|
|
173
173
|
const selectedDomainEvent = domainEventMap[normalizedName] || null;
|
|
174
174
|
|
|
@@ -218,8 +218,8 @@ async function generateKafkaEventCommand(moduleName, eventName) {
|
|
|
218
218
|
|
|
219
219
|
if (!isBatch && generated.length === 1) {
|
|
220
220
|
const r = generated[0];
|
|
221
|
-
const topicSnake = toSnakeCase(eventNames[0]).toUpperCase();
|
|
222
|
-
const topicKebab = toKebabCase(eventNames[0]);
|
|
221
|
+
const topicSnake = toSnakeCase(stripEventSuffix(eventNames[0])).toUpperCase();
|
|
222
|
+
const topicKebab = toKebabCase(stripEventSuffix(eventNames[0]));
|
|
223
223
|
console.log(chalk.blue('\n✅ Kafka event configured successfully!'));
|
|
224
224
|
console.log(chalk.white(`\n Event: ${r.name}`));
|
|
225
225
|
console.log(chalk.white(` Topic: ${topicSnake} (${topicKebab})`));
|
|
@@ -393,6 +393,15 @@ async function createOrUpdateKafkaMessageBroker(projectDir, packagePath, context
|
|
|
393
393
|
// Update existing implementation
|
|
394
394
|
let content = await fs.readFile(adapterPath, 'utf-8');
|
|
395
395
|
|
|
396
|
+
// If this is a mock impl (generated by build --mock), replace it wholesale
|
|
397
|
+
// with the real Kafka implementation before proceeding.
|
|
398
|
+
const isMockImpl = content.includes('ApplicationEventPublisher') && !content.includes('KafkaTemplate');
|
|
399
|
+
if (isMockImpl) {
|
|
400
|
+
const templatePath = path.join(__dirname, '..', '..', 'templates', 'kafka-event', 'KafkaMessageBroker.java.ejs');
|
|
401
|
+
await renderAndWrite(templatePath, adapterPath, context);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
396
405
|
// Check if method already exists
|
|
397
406
|
if (content.includes(methodName)) {
|
|
398
407
|
return; // Method already exists
|
|
@@ -566,10 +575,10 @@ async function updateDomainEventHandler(projectDir, packagePath, context) {
|
|
|
566
575
|
return false;
|
|
567
576
|
}
|
|
568
577
|
|
|
569
|
-
// Compute domain event name by stripping '
|
|
570
|
-
// e.g.
|
|
571
|
-
const domainEventName = context.eventClassName.endsWith('
|
|
572
|
-
? context.eventClassName.slice(0, -'
|
|
578
|
+
// Compute domain event name by stripping 'IntegrationEvent' suffix from eventClassName
|
|
579
|
+
// e.g. OrderPlacedIntegrationEvent → OrderPlaced
|
|
580
|
+
const domainEventName = context.eventClassName.endsWith('IntegrationEvent')
|
|
581
|
+
? context.eventClassName.slice(0, -'IntegrationEvent'.length)
|
|
573
582
|
: context.eventClassName;
|
|
574
583
|
|
|
575
584
|
// Guard: the TODO comment must exist (generated by g entities)
|
|
@@ -606,8 +615,10 @@ async function updateDomainEventHandler(projectDir, packagePath, context) {
|
|
|
606
615
|
}
|
|
607
616
|
|
|
608
617
|
// 4. Render the mapping call and replace the TODO block
|
|
618
|
+
// Derive aggregateName from the handler file name (e.g. BikeDomainEventHandler.java → Bike)
|
|
619
|
+
const aggregateName = handlerFile.replace('DomainEventHandler.java', '');
|
|
609
620
|
const templatePath = path.join(__dirname, '..', '..', 'templates', 'kafka-event', 'DomainEventHandlerMethod.ejs');
|
|
610
|
-
const mappingLine = await renderTemplate(templatePath, { ...context, domainEventFields: context.eventFields });
|
|
621
|
+
const mappingLine = await renderTemplate(templatePath, { ...context, domainEventFields: context.eventFields, aggregateName });
|
|
611
622
|
|
|
612
623
|
const todoRegex = new RegExp(
|
|
613
624
|
`([ \\t]*\/\/ TODO: handle ${domainEventName}[^\\n]*\\n)(?:[ \\t]*\/\/[^\\n]*\\n)*`
|
|
@@ -639,4 +650,80 @@ function injectImportIntoFile(content, importStatement) {
|
|
|
639
650
|
}
|
|
640
651
|
}
|
|
641
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Returns the name of the installed message broker ('kafka' | 'rabbitmq') or null.
|
|
655
|
+
* Extensibility: add 'rabbitmq' support here when `eva add rabbitmq-client` is implemented.
|
|
656
|
+
* @param {import('../utils/config-manager')} configManager
|
|
657
|
+
* @returns {Promise<'kafka'|'rabbitmq'|null>}
|
|
658
|
+
*/
|
|
659
|
+
async function getInstalledBroker(configManager) {
|
|
660
|
+
if (await configManager.featureExists('kafka')) return 'kafka';
|
|
661
|
+
if (await configManager.featureExists('rabbitmq')) return 'rabbitmq';
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Strip the conventional Java 'Event' suffix from an event class name
|
|
667
|
+
* before deriving the Kafka topic name.
|
|
668
|
+
* ProductPublishedEvent → ProductPublished → PRODUCT_PUBLISHED
|
|
669
|
+
*
|
|
670
|
+
* If the event has an explicit `topic:` property in domain.yaml, that takes
|
|
671
|
+
* precedence and this function is not called.
|
|
672
|
+
*/
|
|
673
|
+
function stripEventSuffix(name) {
|
|
674
|
+
return name.endsWith('Event') ? name.slice(0, -'Event'.length) : name;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Build a Kafka event generation context for a given domain event.
|
|
679
|
+
* Intended for use by `generate-entities.js` to auto-wire broker integration events
|
|
680
|
+
* without requiring a separate `eva g kafka-event` invocation.
|
|
681
|
+
*
|
|
682
|
+
* @param {string} packageName
|
|
683
|
+
* @param {string} moduleName
|
|
684
|
+
* @param {{ name: string, topic?: string, fields: Array }} domainEvent - Domain event from parsed domain.yaml
|
|
685
|
+
* @param {{ partitions?: number, replicas?: number }} [options]
|
|
686
|
+
* @returns {Object} context ready for generateSingleKafkaEvent()
|
|
687
|
+
*/
|
|
688
|
+
function buildKafkaEventContext(packageName, moduleName, domainEvent, { partitions = 3, replicas = 1 } = {}) {
|
|
689
|
+
const normalizedName = toPascalCase(domainEvent.name);
|
|
690
|
+
const integrationEventClassName = normalizedName.endsWith('IntegrationEvent')
|
|
691
|
+
? normalizedName
|
|
692
|
+
: `${normalizedName}IntegrationEvent`;
|
|
693
|
+
// If an explicit topic is declared in domain.yaml, use it as source of truth.
|
|
694
|
+
// Otherwise strip the 'Event' suffix before deriving snake/kebab names so that
|
|
695
|
+
// ProductPublishedEvent → PRODUCT_PUBLISHED (not PRODUCT_PUBLISHED_EVENT).
|
|
696
|
+
const topicBase = domainEvent.topic
|
|
697
|
+
? domainEvent.topic.trim().toUpperCase().replace(/-/g, '_')
|
|
698
|
+
: toSnakeCase(stripEventSuffix(domainEvent.name)).toUpperCase();
|
|
699
|
+
const topicNameSnake = topicBase;
|
|
700
|
+
const topicNameKebab = topicBase.toLowerCase().replace(/_/g, '-');
|
|
701
|
+
const topicNameCamel = toCamelCase(topicNameKebab);
|
|
702
|
+
const topicSpringProperty = `\${topics.${topicNameKebab}}`;
|
|
703
|
+
return {
|
|
704
|
+
packageName,
|
|
705
|
+
moduleName,
|
|
706
|
+
modulePascalCase: toPascalCase(moduleName),
|
|
707
|
+
moduleCamelCase: toCamelCase(moduleName),
|
|
708
|
+
kafkaMessageBrokerClassName: `${toPascalCase(moduleName)}KafkaMessageBroker`,
|
|
709
|
+
eventClassName: integrationEventClassName,
|
|
710
|
+
topicNameSnake,
|
|
711
|
+
topicNameKebab,
|
|
712
|
+
topicNameCamel,
|
|
713
|
+
topicPropertyKey: topicNameKebab,
|
|
714
|
+
topicPropertyValue: topicNameSnake,
|
|
715
|
+
topicSpringProperty,
|
|
716
|
+
partitions,
|
|
717
|
+
replicas,
|
|
718
|
+
eventFields: domainEvent.fields || null
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
642
722
|
module.exports = generateKafkaEventCommand;
|
|
723
|
+
module.exports.generateSingleKafkaEvent = generateSingleKafkaEvent;
|
|
724
|
+
module.exports.buildKafkaEventContext = buildKafkaEventContext;
|
|
725
|
+
module.exports.getInstalledBroker = getInstalledBroker;
|
|
726
|
+
module.exports.updateKafkaYml = updateKafkaYml;
|
|
727
|
+
module.exports.generateEventRecord = generateEventRecord;
|
|
728
|
+
module.exports.createOrUpdateMessageBroker = createOrUpdateMessageBroker;
|
|
729
|
+
module.exports.updateDomainEventHandler = updateDomainEventHandler;
|
|
@@ -8,7 +8,7 @@ class BaseGenerator {
|
|
|
8
8
|
constructor(context) {
|
|
9
9
|
this.context = context;
|
|
10
10
|
this.templatesDir = path.join(__dirname, '../../templates/base');
|
|
11
|
-
this.projectDir = path.join(process.cwd(), context.
|
|
11
|
+
this.projectDir = path.join(process.cwd(), context.projectName);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
async generate() {
|
|
@@ -63,6 +63,7 @@ class BaseGenerator {
|
|
|
63
63
|
await fs.ensureDir(resources);
|
|
64
64
|
await fs.ensureDir(path.join(resources, 'static'));
|
|
65
65
|
await fs.ensureDir(path.join(resources, 'templates'));
|
|
66
|
+
await fs.ensureDir(path.join(this.projectDir, 'system'));
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
async generateApplication() {
|
|
@@ -130,6 +131,12 @@ class BaseGenerator {
|
|
|
130
131
|
path.join(this.projectDir, 'README.md'));
|
|
131
132
|
await this.generateFile('root/AGENTS.md.ejs',
|
|
132
133
|
path.join(this.projectDir, 'AGENTS.md'));
|
|
134
|
+
await this.generateFile('root/system.yaml.ejs',
|
|
135
|
+
path.join(this.projectDir, 'system.yaml'));
|
|
136
|
+
await this.generateFile('root/skill-build-system-yaml.ejs',
|
|
137
|
+
path.join(this.projectDir, '.agents', 'skills', 'build-system-yaml', 'SKILL.md'));
|
|
138
|
+
await this.generateFile('root/skill-build-domain-yaml-references-generate-entities.md.ejs',
|
|
139
|
+
path.join(this.projectDir, '.agents', 'skills', 'build-system-yaml', 'references', 'GENERATE_ENTITIES.md'));
|
|
133
140
|
|
|
134
141
|
if (this.context.features.includeDocker) {
|
|
135
142
|
await this.generateFile('docker/docker-compose.yaml.ejs',
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs-extra');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
const { parseDomainYaml } = require('../utils/yaml-to-entity');
|
|
9
|
+
const { renderTemplate } = require('../utils/template-engine');
|
|
10
|
+
const { toKebabCase, toCamelCase, toPackagePath } = require('../utils/naming');
|
|
11
|
+
const { initSeed, generateFakeValue, generateFakeBody, generateFakeId } = require('../utils/fake-data');
|
|
12
|
+
|
|
13
|
+
const TEMPLATES_DIR = path.join(__dirname, '../../templates/postman');
|
|
14
|
+
|
|
15
|
+
// Audit / internal fields excluded from command bodies
|
|
16
|
+
const EXCLUDED_FIELDS = new Set([
|
|
17
|
+
'id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'deletedAt',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Determine which aggregate an operation belongs to based on use case naming.
|
|
22
|
+
* E.g. "CreateProduct" → "Product", "FindAllCategorys" → "Category".
|
|
23
|
+
*
|
|
24
|
+
* @param {string} useCase - e.g. "CreateProduct"
|
|
25
|
+
* @param {Array} aggregates - parsed aggregates with `.name`
|
|
26
|
+
* @returns {string|null} - aggregate name or null
|
|
27
|
+
*/
|
|
28
|
+
function resolveOwnerAggregate(useCase, aggregates) {
|
|
29
|
+
const ucLower = useCase.toLowerCase();
|
|
30
|
+
// Sort longest name first so "UserProfile" matches before "User"
|
|
31
|
+
const sorted = [...aggregates].sort((a, b) => b.name.length - a.name.length);
|
|
32
|
+
for (const agg of sorted) {
|
|
33
|
+
if (ucLower.includes(agg.name.toLowerCase())) return agg.name;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate a unified Postman collection covering every module in the system.
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} opts
|
|
42
|
+
* @param {string} opts.projectDir - Absolute path to the project root
|
|
43
|
+
* @param {string} opts.systemDir - Absolute path to the system/ directory
|
|
44
|
+
* @param {string} opts.packageName - Full Java package name (e.g. "com.example.myapp")
|
|
45
|
+
* @param {Object} opts.systemConfig - Parsed system.yaml object
|
|
46
|
+
* @param {Object} opts.projectConfig - Parsed .eva4j.json project config
|
|
47
|
+
* @returns {Promise<string|null>} - Path to the generated file, or null on error
|
|
48
|
+
*/
|
|
49
|
+
async function generateUnifiedPostmanCollection({
|
|
50
|
+
projectDir,
|
|
51
|
+
systemDir,
|
|
52
|
+
packageName,
|
|
53
|
+
systemConfig,
|
|
54
|
+
projectConfig,
|
|
55
|
+
}) {
|
|
56
|
+
const systemName = systemConfig.system?.name || projectConfig.projectName || projectConfig.artifactId || 'eva4j-app';
|
|
57
|
+
const port = projectConfig.server?.port || 8040;
|
|
58
|
+
const modules = systemConfig.modules || [];
|
|
59
|
+
|
|
60
|
+
if (!modules.length) return null;
|
|
61
|
+
|
|
62
|
+
// Seed faker for deterministic output
|
|
63
|
+
initSeed(42);
|
|
64
|
+
|
|
65
|
+
// ── Collect module contexts ───────────────────────────────────────────────
|
|
66
|
+
const moduleContexts = [];
|
|
67
|
+
|
|
68
|
+
for (const mod of modules) {
|
|
69
|
+
const yamlPath = path.join(systemDir, `${mod.name}.yaml`);
|
|
70
|
+
if (!(await fs.pathExists(yamlPath))) continue;
|
|
71
|
+
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = await parseDomainYaml(yamlPath, packageName, mod.name);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.log(chalk.yellow(` ⚠️ Could not parse ${mod.name}.yaml for Postman: ${err.message}`));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { aggregates, allEnums, endpoints } = parsed;
|
|
81
|
+
const aggregateContexts = [];
|
|
82
|
+
|
|
83
|
+
for (const agg of aggregates) {
|
|
84
|
+
const rootEntity = agg.rootEntity;
|
|
85
|
+
const idField = rootEntity.fields.find(f => f.name === 'id');
|
|
86
|
+
const idType = idField ? idField.javaType : 'String';
|
|
87
|
+
const exampleId = generateFakeId(idType);
|
|
88
|
+
const trackUser = rootEntity.audit?.trackUser === true;
|
|
89
|
+
const valueObjects = agg.valueObjects || [];
|
|
90
|
+
|
|
91
|
+
// Command fields: exclude id, audit, readOnly, deletedAt
|
|
92
|
+
const commandFields = rootEntity.fields.filter(
|
|
93
|
+
f => !EXCLUDED_FIELDS.has(f.name) && !f.readOnly
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Build fake body for create/update
|
|
97
|
+
const defaultBody = generateFakeBody(commandFields, [], allEnums, valueObjects);
|
|
98
|
+
|
|
99
|
+
if (endpoints && endpoints.versions && endpoints.versions.length > 0) {
|
|
100
|
+
// ── Endpoint-driven ───────────────────────────────────────────────
|
|
101
|
+
const operations = [];
|
|
102
|
+
const bodies = {};
|
|
103
|
+
|
|
104
|
+
for (const version of endpoints.versions) {
|
|
105
|
+
for (const op of version.operations) {
|
|
106
|
+
// Classify operation → which aggregate it belongs to
|
|
107
|
+
const owner = resolveOwnerAggregate(op.useCase, aggregates);
|
|
108
|
+
if (owner && owner !== agg.name) continue;
|
|
109
|
+
// If no aggregate could be resolved, assign to the first aggregate
|
|
110
|
+
if (!owner && agg !== aggregates[0]) continue;
|
|
111
|
+
|
|
112
|
+
const basePath = endpoints.basePath || '/';
|
|
113
|
+
operations.push({
|
|
114
|
+
useCase: op.useCase,
|
|
115
|
+
method: op.method,
|
|
116
|
+
path: op.path || '/',
|
|
117
|
+
basePath,
|
|
118
|
+
version: version.version,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Generate body for write operations
|
|
122
|
+
if (op.method === 'POST' || op.method === 'PUT' || op.method === 'PATCH') {
|
|
123
|
+
// Re-seed per operation so bodies vary
|
|
124
|
+
bodies[op.useCase] = generateFakeBody(commandFields, [], allEnums, valueObjects);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
aggregateContexts.push({
|
|
130
|
+
name: agg.name,
|
|
131
|
+
trackUser,
|
|
132
|
+
idType,
|
|
133
|
+
exampleId,
|
|
134
|
+
resourceNameKebab: toKebabCase(agg.name),
|
|
135
|
+
operations,
|
|
136
|
+
defaultCrud: false,
|
|
137
|
+
bodies,
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
// ── Default CRUD (no endpoints section) ──────────────────────────
|
|
141
|
+
aggregateContexts.push({
|
|
142
|
+
name: agg.name,
|
|
143
|
+
trackUser,
|
|
144
|
+
idType,
|
|
145
|
+
exampleId,
|
|
146
|
+
resourceNameKebab: toKebabCase(agg.name),
|
|
147
|
+
operations: null,
|
|
148
|
+
defaultCrud: true,
|
|
149
|
+
bodies: { default: defaultBody },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (aggregateContexts.length > 0) {
|
|
155
|
+
moduleContexts.push({
|
|
156
|
+
name: mod.name,
|
|
157
|
+
aggregates: aggregateContexts,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!moduleContexts.length) return null;
|
|
163
|
+
|
|
164
|
+
// ── Render template ─────────────────────────────────────────────────────
|
|
165
|
+
const templatePath = path.join(TEMPLATES_DIR, 'UnifiedCollection.json.ejs');
|
|
166
|
+
const collectionId = crypto.randomUUID();
|
|
167
|
+
|
|
168
|
+
const context = {
|
|
169
|
+
systemName,
|
|
170
|
+
collectionId,
|
|
171
|
+
port,
|
|
172
|
+
modules: moduleContexts,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const content = await renderTemplate(templatePath, context);
|
|
176
|
+
|
|
177
|
+
// ── Write output ────────────────────────────────────────────────────────
|
|
178
|
+
const outputDir = path.join(projectDir, 'postman');
|
|
179
|
+
await fs.ensureDir(outputDir);
|
|
180
|
+
|
|
181
|
+
const outputFileName = `${toKebabCase(systemName)}-Postman-Collection.json`;
|
|
182
|
+
const outputPath = path.join(outputDir, outputFileName);
|
|
183
|
+
await fs.writeFile(outputPath, content, 'utf-8');
|
|
184
|
+
|
|
185
|
+
return outputPath;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { generateUnifiedPostmanCollection };
|
|
@@ -194,6 +194,12 @@ class SharedGenerator {
|
|
|
194
194
|
path.join(handlerExceptionPath, 'HandlerExceptions.java'));
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
async generateMockEvent(basePath) {
|
|
198
|
+
const destPath = path.join(basePath, 'infrastructure', 'mockEvent', 'MockEvent.java');
|
|
199
|
+
const templatePath = path.join(__dirname, '../../templates/mock/MockEvent.java.ejs');
|
|
200
|
+
await renderAndWrite(templatePath, destPath, this.context, { overwrite: false });
|
|
201
|
+
}
|
|
202
|
+
|
|
197
203
|
async generateConfigurations(basePath) {
|
|
198
204
|
const configurationsPath = path.join(basePath, 'infrastructure', 'configurations');
|
|
199
205
|
|
|
@@ -209,6 +215,10 @@ class SharedGenerator {
|
|
|
209
215
|
await this.generateFile('configurations/swaggerConfig/SwaggerConfig.java.ejs',
|
|
210
216
|
path.join(configurationsPath, 'swaggerConfig', 'SwaggerConfig.java'));
|
|
211
217
|
|
|
218
|
+
// Event publication schema fix (Spring Modulith varchar(255) → TEXT)
|
|
219
|
+
await this.generateFile('configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs',
|
|
220
|
+
path.join(configurationsPath, 'eventPublicationConfig', 'EventPublicationSchemaConfig.java'));
|
|
221
|
+
|
|
212
222
|
// UseCase config
|
|
213
223
|
await this.generateFile('configurations/useCaseConfig/UseCaseAutoRegister.java.ejs',
|
|
214
224
|
path.join(configurationsPath, 'useCaseConfig', 'UseCaseAutoRegister.java'));
|
|
@@ -151,6 +151,60 @@ class ConfigManager {
|
|
|
151
151
|
|
|
152
152
|
return config;
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Persist mock backup data into .eva4j.json so it survives process restarts.
|
|
157
|
+
* @param {Object} backups - Map of { key: { path, content } }
|
|
158
|
+
*/
|
|
159
|
+
async saveMockBackup(backups, opts = {}) {
|
|
160
|
+
const config = await this.loadProjectConfig();
|
|
161
|
+
if (!config) throw new Error('Project configuration not found.');
|
|
162
|
+
|
|
163
|
+
config._mockBackup = {};
|
|
164
|
+
for (const [key, { path: filePath, content }] of Object.entries(backups)) {
|
|
165
|
+
config._mockBackup[key] = { path: filePath, content };
|
|
166
|
+
}
|
|
167
|
+
config._mockActive = true;
|
|
168
|
+
if (opts.onlyBroker) {
|
|
169
|
+
config._mockOnlyBroker = true;
|
|
170
|
+
}
|
|
171
|
+
config.updatedAt = new Date().toISOString();
|
|
172
|
+
await fs.writeJson(this.configFile, config, { spaces: 2 });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read and clear mock backup from .eva4j.json.
|
|
177
|
+
* @returns {Object|null} backups map or null if no mock backup stored
|
|
178
|
+
*/
|
|
179
|
+
async popMockBackup() {
|
|
180
|
+
const config = await this.loadProjectConfig();
|
|
181
|
+
if (!config || !config._mockBackup) return null;
|
|
182
|
+
|
|
183
|
+
const backups = config._mockBackup;
|
|
184
|
+
delete config._mockBackup;
|
|
185
|
+
delete config._mockActive;
|
|
186
|
+
delete config._mockOnlyBroker;
|
|
187
|
+
config.updatedAt = new Date().toISOString();
|
|
188
|
+
await fs.writeJson(this.configFile, config, { spaces: 2 });
|
|
189
|
+
return backups;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Returns true if a mock backup is currently stored in .eva4j.json.
|
|
194
|
+
*/
|
|
195
|
+
async hasMockBackup() {
|
|
196
|
+
const config = await this.loadProjectConfig();
|
|
197
|
+
return !!(config && config._mockActive);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Returns true if the active mock was started with --only-broker
|
|
202
|
+
* (i.e. only the broker was swapped, DB config was kept unchanged).
|
|
203
|
+
*/
|
|
204
|
+
async hasMockOnlyBroker() {
|
|
205
|
+
const config = await this.loadProjectConfig();
|
|
206
|
+
return !!(config && config._mockOnlyBroker);
|
|
207
|
+
}
|
|
154
208
|
}
|
|
155
209
|
|
|
156
210
|
module.exports = ConfigManager;
|
|
@@ -30,6 +30,7 @@ function buildBaseContext(answers) {
|
|
|
30
30
|
dependencyManagementVersion: defaults.dependencyManagementVersion,
|
|
31
31
|
springModulithVersion: defaults.springModulithVersion,
|
|
32
32
|
springCloudVersion: defaults.springCloudVersion,
|
|
33
|
+
springdocVersion: defaults.springdocVersion,
|
|
33
34
|
gradleVersion: defaults.gradleVersion,
|
|
34
35
|
dependencies: answers.dependencies || [],
|
|
35
36
|
author: answers.author || 'Generated by eva4j',
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates Mermaid classDiagram text per module from parsed domain.yaml objects.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} domainConfigs Plain object { [moduleName]: parsedDomainYaml }
|
|
7
|
+
* @returns {{ [moduleName]: string }} Map of module → Mermaid diagram text (empty string if no aggregates)
|
|
8
|
+
*/
|
|
9
|
+
function generateDomainDiagrams(domainConfigs) {
|
|
10
|
+
const result = {};
|
|
11
|
+
for (const [moduleName, config] of Object.entries(domainConfigs)) {
|
|
12
|
+
result[moduleName] = generateModuleDiagram(moduleName, config);
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const AUDIT_FIELDS = new Set(['createdAt', 'updatedAt', 'createdBy', 'updatedBy']);
|
|
20
|
+
|
|
21
|
+
/** Normalize to PascalCase (capitalize first letter only). */
|
|
22
|
+
function toPascal(name) {
|
|
23
|
+
if (!name) return name;
|
|
24
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fieldPrefix(field) {
|
|
28
|
+
if (field.hidden) return '-';
|
|
29
|
+
if (field.readOnly) return '~';
|
|
30
|
+
return '+';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Per-module diagram builder ────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function generateModuleDiagram(moduleName, config) {
|
|
36
|
+
const aggregates = config.aggregates || [];
|
|
37
|
+
if (aggregates.length === 0) return '';
|
|
38
|
+
|
|
39
|
+
// Flat diagram — namespace blocks are avoided because Mermaid's layout engine
|
|
40
|
+
// can place inner classes outside the visual boundary, causing overflow.
|
|
41
|
+
// Aggregates are separated by %% comment dividers instead.
|
|
42
|
+
const lines = ['classDiagram'];
|
|
43
|
+
const relationships = []; // Collected per-aggregate, emitted at the end
|
|
44
|
+
const notes = []; // `note for ClassName "..."` — for cross-aggregate refs
|
|
45
|
+
|
|
46
|
+
// Build a set of all entity/VO/enum PascalCase class names defined in this module
|
|
47
|
+
// so we can distinguish intra-module from cross-aggregate relationships.
|
|
48
|
+
const localClasses = new Set();
|
|
49
|
+
for (const aggregate of aggregates) {
|
|
50
|
+
for (const e of aggregate.entities || []) localClasses.add(toPascal(e.name));
|
|
51
|
+
for (const v of aggregate.valueObjects || []) localClasses.add(toPascal(v.name));
|
|
52
|
+
for (const en of aggregate.enums || []) localClasses.add(toPascal(en.name));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const aggregate of aggregates) {
|
|
56
|
+
const entities = aggregate.entities || [];
|
|
57
|
+
const valueObjects = aggregate.valueObjects || [];
|
|
58
|
+
const enums = aggregate.enums || [];
|
|
59
|
+
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(` %% ── Aggregate: ${aggregate.name} ─────────────────────`);
|
|
62
|
+
|
|
63
|
+
// ── Entities ─────────────────────────────────────────────────────────────
|
|
64
|
+
for (const entity of entities) {
|
|
65
|
+
const className = toPascal(entity.name);
|
|
66
|
+
lines.push(` class ${className} {`);
|
|
67
|
+
lines.push(` ${entity.isRoot ? '<<aggregate root>>' : '<<entity>>'}`);
|
|
68
|
+
|
|
69
|
+
for (const field of entity.fields || []) {
|
|
70
|
+
if (AUDIT_FIELDS.has(field.name)) continue;
|
|
71
|
+
lines.push(` ${fieldPrefix(field)}${field.type} ${field.name}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (entity.audit?.enabled) {
|
|
75
|
+
lines.push(` +LocalDateTime createdAt`);
|
|
76
|
+
lines.push(` +LocalDateTime updatedAt`);
|
|
77
|
+
}
|
|
78
|
+
if (entity.audit?.trackUser) {
|
|
79
|
+
lines.push(` +String createdBy`);
|
|
80
|
+
lines.push(` +String updatedBy`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lines.push(` }`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Value objects ─────────────────────────────────────────────────────────
|
|
87
|
+
for (const vo of valueObjects) {
|
|
88
|
+
const className = toPascal(vo.name);
|
|
89
|
+
lines.push(` class ${className} {`);
|
|
90
|
+
lines.push(` <<value object>>`);
|
|
91
|
+
for (const field of vo.fields || []) {
|
|
92
|
+
lines.push(` +${field.type} ${field.name}`);
|
|
93
|
+
}
|
|
94
|
+
lines.push(` }`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Enums ─────────────────────────────────────────────────────────────────
|
|
98
|
+
for (const en of enums) {
|
|
99
|
+
const className = toPascal(en.name);
|
|
100
|
+
lines.push(` class ${className} {`);
|
|
101
|
+
lines.push(` <<enumeration>>`);
|
|
102
|
+
for (const val of en.values || []) {
|
|
103
|
+
lines.push(` ${val}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push(` }`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Collect relationships ─────────────────────────────────────────────────
|
|
109
|
+
const voNames = new Set(valueObjects.map((v) => toPascal(v.name)));
|
|
110
|
+
const enumNames = new Set(enums.map((e) => toPascal(e.name)));
|
|
111
|
+
|
|
112
|
+
for (const entity of entities) {
|
|
113
|
+
const srcClass = toPascal(entity.name);
|
|
114
|
+
|
|
115
|
+
// Structural JPA relationships — only emit if target is a local class
|
|
116
|
+
for (const rel of entity.relationships || []) {
|
|
117
|
+
const target = toPascal(rel.target || rel.targetEntity || '');
|
|
118
|
+
if (!target) continue;
|
|
119
|
+
if (!localClasses.has(target)) continue; // skip cross-aggregate targets
|
|
120
|
+
const label = rel.mappedBy ? ` : ${rel.mappedBy}` : '';
|
|
121
|
+
switch (rel.type) {
|
|
122
|
+
case 'OneToMany':
|
|
123
|
+
relationships.push(` ${srcClass} "1" --o "*" ${target}${label}`);
|
|
124
|
+
break;
|
|
125
|
+
case 'OneToOne':
|
|
126
|
+
relationships.push(` ${srcClass} "1" --o "1" ${target}${label}`);
|
|
127
|
+
break;
|
|
128
|
+
case 'ManyToOne':
|
|
129
|
+
relationships.push(` ${srcClass} "*" --> "1" ${target}`);
|
|
130
|
+
break;
|
|
131
|
+
case 'ManyToMany':
|
|
132
|
+
relationships.push(` ${srcClass} "*" --> "*" ${target}`);
|
|
133
|
+
break;
|
|
134
|
+
default:
|
|
135
|
+
relationships.push(` ${srcClass} --> ${target}${label}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Field-level type references within the same module (deduplicated)
|
|
140
|
+
const seenEdges = new Set();
|
|
141
|
+
const crossRefs = []; // cross-aggregate reference fields for this entity
|
|
142
|
+
|
|
143
|
+
for (const field of entity.fields || []) {
|
|
144
|
+
if (AUDIT_FIELDS.has(field.name)) continue;
|
|
145
|
+
|
|
146
|
+
const fieldType = toPascal(field.type);
|
|
147
|
+
|
|
148
|
+
if (enumNames.has(fieldType)) {
|
|
149
|
+
const key = `${srcClass}-->${fieldType}`;
|
|
150
|
+
if (!seenEdges.has(key)) {
|
|
151
|
+
relationships.push(` ${srcClass} --> ${fieldType} : ${field.name}`);
|
|
152
|
+
seenEdges.add(key);
|
|
153
|
+
}
|
|
154
|
+
} else if (voNames.has(fieldType)) {
|
|
155
|
+
const key = `${srcClass}*--${fieldType}`;
|
|
156
|
+
if (!seenEdges.has(key)) {
|
|
157
|
+
relationships.push(` ${srcClass} *-- ${fieldType} : ${field.name}`);
|
|
158
|
+
seenEdges.add(key);
|
|
159
|
+
}
|
|
160
|
+
} else if (field.reference) {
|
|
161
|
+
// Cross-aggregate reference: render as a note instead of an arrow
|
|
162
|
+
// to avoid undefined ghost nodes appearing outside the diagram.
|
|
163
|
+
const refModule = field.reference.module;
|
|
164
|
+
const refAggregate = field.reference.aggregate;
|
|
165
|
+
const label =
|
|
166
|
+
refModule && refModule !== moduleName
|
|
167
|
+
? `${field.name} → ${refAggregate} (${refModule})`
|
|
168
|
+
: `${field.name} → ${refAggregate}`;
|
|
169
|
+
crossRefs.push(label);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (crossRefs.length > 0) {
|
|
174
|
+
notes.push(` note for ${srcClass} "refs: ${crossRefs.join(', ')}"`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (relationships.length > 0) {
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push(...relationships);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (notes.length > 0) {
|
|
185
|
+
lines.push('');
|
|
186
|
+
lines.push(...notes);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return lines.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { generateDomainDiagrams };
|