diff --git a/bom/pom.xml b/bom/pom.xml index 786500af..bba4784c 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -61,6 +61,11 @@ akces-eventcatalog ${project.version} + + org.elasticsoftwarefoundation.akces + akces-codegen + ${project.version} + diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md new file mode 100644 index 00000000..49cb847c --- /dev/null +++ b/main/codegen/SCHEMA_DIFFERENCES.md @@ -0,0 +1,184 @@ +# Akces Event Model Schema vs Original Event-Modeling Spec + +This document describes the differences between the Akces augmented event-model schema +([`akces-event-model.schema.json`](src/main/resources/akces-event-model.schema.json)) and the +original [event-modeling specification schema](https://github.com/dilgerma/event-modeling-spec/blob/main/eventmodeling.schema.json) +by Martin Dilger. + +The Akces schema is a **strict superset** of the original — it preserves every definition from the +original schema unchanged (Slice, Element, ScreenImage, Table, Specification, SpecificationStep, +Comment, Actor, Dependency) and extends it with Akces Framework-specific properties for aggregate +code generation. + +--- + +## Root-Level Changes + +| Aspect | Original Schema | Akces Schema | +|---|---|---| +| **title** | *(none)* | `"Akces Event Model Definition"` | +| **description** | *(none)* | Describes the Akces extension of the event-modeling spec | +| **properties** | `slices` | `packageName`, `aggregateConfig`, `slices` | +| **required** | `["slices"]` | `["packageName", "aggregateConfig", "slices"]` | + +### New root-level property: `packageName` + +```json +"packageName": { + "type": "string", + "description": "The base Java package for generated code" +} +``` + +Specifies the base Java package name used in the JSON definition. The code generator uses its own +`basePackage` constructor parameter for the generated `package` declarations and directory structure. + +### New root-level property: `aggregateConfig` + +```json +"aggregateConfig": { + "type": "object", + "description": "Per-aggregate configuration keyed by aggregate name", + "additionalProperties": { "$ref": "#/$defs/AggregateConfig" } +} +``` + +A map keyed by aggregate name (e.g., `"Account"`, `"Wallet"`) where each value is an +`AggregateConfig` object containing Akces-specific metadata for that aggregate. + +--- + +## New Definition: `AggregateConfig` + +Akces-specific configuration for an aggregate, used to generate the state record and aggregate class +annotations. + +| Property | Type | Required | Description | +|---|---|---|---| +| `indexed` | `boolean` | No | Whether the aggregate is indexed (maps to `@AggregateInfo(indexed = true)`) | +| `indexName` | `string` | No | The index name (maps to `@AggregateInfo(indexName = "...")`) | +| `generateGDPRKeyOnCreate` | `boolean` | No | Whether to generate a GDPR encryption key on aggregate creation (maps to `@AggregateInfo(generateGDPRKeyOnCreate = true)`) | +| `stateVersion` | `integer` (≥ 1) | No | The version of the aggregate state (maps to `@AggregateStateInfo(version = ...)`) | +| `stateFields` | `Field[]` | **Yes** | The fields of the aggregate state record (reuses the standard `Field` definition) | + +**Example:** + +```json +"aggregateConfig": { + "Account": { + "indexed": true, + "indexName": "Users", + "generateGDPRKeyOnCreate": true, + "stateVersion": 1, + "stateFields": [ + { "name": "userId", "type": "String", "idAttribute": true }, + { "name": "firstName", "type": "String", "piiData": true }, + { "name": "email", "type": "String", "piiData": true } + ] + } +} +``` + +--- + +## Modified Definition: `Slice` + +The `Slice` definition has one addition compared to the original event-modeling spec schema: + +| Property | Type | Required | Description | +|---|---|---|---| +| **`external_events`** *(new)* | `Element[]` | No | External events from other aggregates consumed in this slice | + +All other `Slice` properties are unchanged from the original schema. + +External events use the same `Element` definition as regular events and commands. They represent +events produced by other aggregates that are consumed by the aggregate in this slice. This is used +for cross-aggregate event handling, particularly for process managers. + +**Example** — Wallet aggregate consuming Account's `AccountCreatedEvent`: + +```json +{ + "id": "create-wallet", + "title": "Create Wallet", + "sliceType": "AUTOMATION", + "aggregates": ["Wallet"], + "commands": [], + "events": [ + { + "id": "wallet-created-evt", + "title": "WalletCreated", + "type": "EVENT", + "aggregate": "Wallet", + "createsAggregate": true, + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true } + ], + "dependencies": [] + } + ], + "external_events": [ + { + "id": "account-created-ext", + "title": "AccountCreated", + "type": "EVENT", + "aggregate": "Account", + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true } + ], + "dependencies": [] + } + ], + "readmodels": [], + "screens": [], + "processors": [], + "tables": [], + "specifications": [] +} +``` + +--- + +## Modified Definition: `Field` + +The `Field` definition (used in Element fields for commands, events, read models, etc.) has one +addition compared to the original schema: + +| Property | Type | Description | +|---|---|---| +| **`piiData`** *(new)* | `boolean` | Whether this field contains PII data requiring GDPR protection | + +All other `Field` properties are unchanged from the original schema: `name`, `type`, `example`, +`subfields`, `mapping`, `optional`, `technicalAttribute`, `generated`, `idAttribute`, `schema`, +`cardinality`. + +When `piiData: true` is set on a command or event field, the code generator emits the `@PIIData` +annotation on the corresponding record parameter, ensuring GDPR-sensitive fields are consistently +marked across commands, events, and state records. + +--- + +## Unchanged Definitions + +The following definitions are **identical** to the original event-modeling spec schema: + +- **`Element`** — Represents a command, event, read model, screen, or automation element with fields and dependencies +- **`ScreenImage`** — A screen mockup image reference +- **`Table`** — A data table definition with fields +- **`Specification`** — A BDD-style specification with given/when/then steps +- **`SpecificationStep`** — A step in a specification +- **`Comment`** — A comment with a description +- **`Actor`** — An actor with authentication requirements +- **`Dependency`** — An inbound or outbound dependency between elements + +--- + +## Summary of Additions + +| Addition | Location | Purpose | +|---|---|---| +| `packageName` | Root property | Java package name declared in the definition | +| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields) | +| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition | +| `external_events` | Added to `Slice` | External events from other aggregates consumed in a slice | +| `piiData` | Added to `Field` | Marks command/event fields as containing PII data | diff --git a/main/codegen/pom.xml b/main/codegen/pom.xml new file mode 100644 index 00000000..6d37ee1e --- /dev/null +++ b/main/codegen/pom.xml @@ -0,0 +1,81 @@ + + + + + org.elasticsoftwarefoundation.akces + akces-framework-main + 0.12.1-SNAPSHOT + + 4.0.0 + + akces-codegen + jar + + Elastic Software Foundation :: Akces :: Code Generator + https://github.com/elasticsoftwarefoundation/akces-framework + + + UTF-8 + + + + + tools.jackson.core + jackson-databind + + + org.elasticsoftwarefoundation.akces + akces-api + ${project.version} + test + + + com.github.erosb + everit-json-schema + test + + + org.testng + testng + test + + + + + + + false + ${basedir}/src/main/resources + + **/*.json + + + + + + false + ${basedir}/src/test/resources + + **/*.json + + + + + + + diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java new file mode 100644 index 00000000..bda2de1b --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -0,0 +1,850 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen; + +import org.elasticsoftware.akces.codegen.model.*; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Code generator that reads an event-modeling JSON definition (extended with Akces-specific + * configuration) and generates Akces Framework aggregate source code. + *

+ * The input format combines the + * + * event-modeling specification with Akces-specific aggregate configuration. + *

+ * For each aggregate found in the definition, the generator produces: + *

+ */ +public final class AkcesCodeGenerator { + + private static final String LICENSE_HEADER = """ + /* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + """; + + private final JsonMapper jsonMapper; + private final String basePackage; + + /** + * Creates a new code generator with the given base package. + * + * @param basePackage the base Java package for generated code (e.g., "com.example.aggregates") + */ + public AkcesCodeGenerator(String basePackage) { + this.basePackage = Objects.requireNonNull(basePackage, "basePackage must not be null"); + this.jsonMapper = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .build(); + } + + /** + * Parses an event-modeling definition from an input stream. + */ + public EventModelDefinition parse(InputStream inputStream) { + return jsonMapper.readValue(inputStream, EventModelDefinition.class); + } + + /** + * Parses an event-modeling definition from a file path. + */ + public EventModelDefinition parse(Path path) throws IOException { + try (InputStream is = Files.newInputStream(path)) { + return parse(is); + } + } + + /** + * Generates all Java source files from the given event-modeling definition. + * + * @param definition the parsed event-modeling definition + * @return a list of generated source files + */ + public List generate(EventModelDefinition definition) { + List files = new ArrayList<>(); + + // Group commands and events by aggregate + Map> aggregateSlices = groupByAggregate(definition); + + for (Map.Entry> entry : aggregateSlices.entrySet()) { + String aggregateName = entry.getKey(); + List slices = entry.getValue(); + AggregateConfig config = definition.aggregateConfig() != null + ? definition.aggregateConfig().get(aggregateName) + : null; + + files.addAll(generateAggregate( + basePackage, + aggregateName, + config, + slices)); + } + + return files; + } + + /** + * Generates all Java source files and writes them to the given output directory. + * + * @param definition the parsed event-modeling definition + * @param outputDir the root output directory for generated sources + * @return a list of generated source files + * @throws IOException if an I/O error occurs + */ + public List generateToDirectory(EventModelDefinition definition, Path outputDir) throws IOException { + List files = generate(definition); + for (GeneratedFile file : files) { + Path filePath = outputDir.resolve(file.relativePath()); + Files.createDirectories(filePath.getParent()); + Files.writeString(filePath, file.content()); + } + return files; + } + + // --- Internal data structures --- + + private record SliceData( + Slice slice, + List commands, + List successEvents, + List errorEvents, + List externalEvents + ) { + } + + // --- Grouping logic --- + + private Map> groupByAggregate(EventModelDefinition definition) { + Map> result = new LinkedHashMap<>(); + + for (Slice slice : definition.slices()) { + // Group commands and events by their aggregate + Map> commandsByAggregate = slice.commands().stream() + .filter(e -> e.aggregate() != null) + .collect(Collectors.groupingBy(Element::aggregate, LinkedHashMap::new, Collectors.toList())); + + Map> eventsByAggregate = slice.events().stream() + .filter(e -> e.aggregate() != null) + .collect(Collectors.groupingBy(Element::aggregate, LinkedHashMap::new, Collectors.toList())); + + // Merge all aggregate names from both commands and events + Set aggregateNames = new LinkedHashSet<>(); + aggregateNames.addAll(commandsByAggregate.keySet()); + aggregateNames.addAll(eventsByAggregate.keySet()); + + for (String aggregateName : aggregateNames) { + List commands = commandsByAggregate.getOrDefault(aggregateName, List.of()); + List allEvents = eventsByAggregate.getOrDefault(aggregateName, List.of()); + List successEvents = allEvents.stream().filter(e -> !e.isError()).toList(); + List errorEvents = allEvents.stream().filter(Element::isError).toList(); + + // External events are associated with the consuming aggregate (via the slice's aggregate context) + List externalEvents = slice.externalEvents(); + + result.computeIfAbsent(aggregateName, k -> new ArrayList<>()) + .add(new SliceData(slice, commands, successEvents, errorEvents, externalEvents)); + } + } + + return result; + } + + // --- Code generation --- + + private List generateAggregate( + String basePackage, + String aggregateName, + AggregateConfig config, + List slices) { + + List files = new ArrayList<>(); + String aggregateLower = aggregateName.substring(0, 1).toLowerCase() + aggregateName.substring(1); + String aggregatePackage = basePackage + "." + aggregateLower; + + // Collect all unique commands and events across slices + Map allCommands = new LinkedHashMap<>(); + Map allSuccessEvents = new LinkedHashMap<>(); + Map allErrorEvents = new LinkedHashMap<>(); + + for (SliceData sliceData : slices) { + for (Element cmd : sliceData.commands()) { + allCommands.putIfAbsent(cmd.title(), cmd); + } + for (Element evt : sliceData.successEvents()) { + allSuccessEvents.putIfAbsent(evt.title(), evt); + } + for (Element evt : sliceData.errorEvents()) { + allErrorEvents.putIfAbsent(evt.title(), evt); + } + } + + // Generate command records + for (Element cmd : allCommands.values()) { + files.add(generateCommandRecord(aggregatePackage, cmd)); + } + + // Generate event records + for (Element evt : allSuccessEvents.values()) { + files.add(generateEventRecord(aggregatePackage, evt, false)); + } + for (Element evt : allErrorEvents.values()) { + files.add(generateEventRecord(aggregatePackage, evt, true)); + } + + // Generate state record + if (config != null && !config.stateFields().isEmpty()) { + files.add(generateStateRecord(aggregatePackage, aggregateName, config)); + } + + // Generate aggregate class + files.add(generateAggregateClass( + aggregatePackage, aggregateName, config, slices, + allCommands, allSuccessEvents, allErrorEvents)); + + return files; + } + + // --- Command record generation --- + + private GeneratedFile generateCommandRecord(String aggregatePackage, Element command) { + String className = command.title() + "Command"; + String commandsPackage = aggregatePackage + ".commands"; + + StringBuilder sb = new StringBuilder(); + sb.append(LICENSE_HEADER).append("\n"); + sb.append("package ").append(commandsPackage).append(";\n\n"); + + // Collect imports + Set imports = new TreeSet<>(); + imports.add("org.elasticsoftware.akces.annotations.CommandInfo"); + imports.add("org.elasticsoftware.akces.commands.Command"); + + boolean hasIdAttribute = command.fields().stream().anyMatch(Field::isIdAttribute); + if (hasIdAttribute) { + imports.add("org.elasticsoftware.akces.annotations.AggregateIdentifier"); + } + + boolean hasRequired = command.fields().stream().anyMatch(f -> !f.isOptional()); + boolean hasOptional = command.fields().stream().anyMatch(Field::isOptional); + boolean hasPii = command.fields().stream().anyMatch(Field::isPiiData); + if (hasRequired) { + imports.add("jakarta.validation.constraints.NotNull"); + } + if (hasOptional) { + imports.add("jakarta.annotation.Nullable"); + } + if (hasPii) { + imports.add("org.elasticsoftware.akces.annotations.PIIData"); + } + + collectFieldTypeImports(command.fields(), imports); + + for (String imp : imports) { + sb.append("import ").append(imp).append(";\n"); + } + sb.append("\n"); + + // Annotation + int version = 1; + sb.append("@CommandInfo(type = \"").append(command.title()).append("\", version = ").append(version).append(")\n"); + + // Record declaration + sb.append("public record ").append(className).append("(\n"); + + List fields = command.fields(); + for (int i = 0; i < fields.size(); i++) { + Field field = fields.get(i); + sb.append(" "); + appendFieldAnnotations(sb, field); + sb.append(mapFieldType(field)).append(" ").append(field.name()); + if (i < fields.size() - 1) { + sb.append(",\n"); + } else { + sb.append("\n"); + } + } + + sb.append(") implements Command {\n"); + + // getAggregateId method + String idFieldName = findIdFieldName(fields); + sb.append(" @Override\n"); + sb.append(" public String getAggregateId() {\n"); + sb.append(" return ").append(idFieldName).append("();\n"); + sb.append(" }\n"); + sb.append("}\n"); + + String relativePath = commandsPackage.replace('.', '/') + "/" + className + ".java"; + return new GeneratedFile(relativePath, sb.toString()); + } + + // --- Event record generation --- + + private GeneratedFile generateEventRecord(String aggregatePackage, Element event, boolean isError) { + String className = event.title() + "Event"; + String eventsPackage = aggregatePackage + ".events"; + + StringBuilder sb = new StringBuilder(); + sb.append(LICENSE_HEADER).append("\n"); + sb.append("package ").append(eventsPackage).append(";\n\n"); + + // Collect imports + Set imports = new TreeSet<>(); + imports.add("org.elasticsoftware.akces.annotations.DomainEventInfo"); + if (isError) { + imports.add("org.elasticsoftware.akces.events.ErrorEvent"); + } else { + imports.add("org.elasticsoftware.akces.events.DomainEvent"); + } + + boolean hasIdAttribute = event.fields().stream().anyMatch(Field::isIdAttribute); + if (hasIdAttribute) { + imports.add("org.elasticsoftware.akces.annotations.AggregateIdentifier"); + } + + boolean hasRequired = event.fields().stream().anyMatch(f -> !f.isOptional()); + boolean hasOptional = event.fields().stream().anyMatch(Field::isOptional); + boolean hasPii = event.fields().stream().anyMatch(Field::isPiiData); + if (hasRequired) { + imports.add("jakarta.validation.constraints.NotNull"); + } + if (hasOptional) { + imports.add("jakarta.annotation.Nullable"); + } + if (hasPii) { + imports.add("org.elasticsoftware.akces.annotations.PIIData"); + } + + collectFieldTypeImports(event.fields(), imports); + + for (String imp : imports) { + sb.append("import ").append(imp).append(";\n"); + } + sb.append("\n"); + + // Annotation + sb.append("@DomainEventInfo(type = \"").append(event.title()).append("\")\n"); + + // Record declaration + sb.append("public record ").append(className).append("(\n"); + + List fields = event.fields(); + for (int i = 0; i < fields.size(); i++) { + Field field = fields.get(i); + sb.append(" "); + appendFieldAnnotations(sb, field); + sb.append(mapFieldType(field)).append(" ").append(field.name()); + if (i < fields.size() - 1) { + sb.append(",\n"); + } else { + sb.append("\n"); + } + } + + String interfaceName = isError ? "ErrorEvent" : "DomainEvent"; + sb.append(") implements ").append(interfaceName).append(" {\n"); + + // getAggregateId method + String idFieldName = findIdFieldName(fields); + sb.append(" @Override\n"); + sb.append(" public String getAggregateId() {\n"); + sb.append(" return ").append(idFieldName).append("();\n"); + sb.append(" }\n"); + sb.append("}\n"); + + String relativePath = eventsPackage.replace('.', '/') + "/" + className + ".java"; + return new GeneratedFile(relativePath, sb.toString()); + } + + // --- State record generation --- + + private GeneratedFile generateStateRecord(String aggregatePackage, String aggregateName, AggregateConfig config) { + String className = aggregateName + "State"; + + StringBuilder sb = new StringBuilder(); + sb.append(LICENSE_HEADER).append("\n"); + sb.append("package ").append(aggregatePackage).append(";\n\n"); + + // Collect imports + Set imports = new TreeSet<>(); + imports.add("org.elasticsoftware.akces.aggregate.AggregateState"); + imports.add("org.elasticsoftware.akces.annotations.AggregateStateInfo"); + + boolean hasIdAttribute = config.stateFields().stream().anyMatch(Field::isIdAttribute); + if (hasIdAttribute) { + imports.add("org.elasticsoftware.akces.annotations.AggregateIdentifier"); + } + + boolean hasRequired = config.stateFields().stream().anyMatch(f -> !f.isOptional()); + boolean hasOptional = config.stateFields().stream().anyMatch(Field::isOptional); + boolean hasPii = config.stateFields().stream().anyMatch(Field::isPiiData); + + if (hasRequired) { + imports.add("jakarta.validation.constraints.NotNull"); + } + if (hasOptional) { + imports.add("jakarta.annotation.Nullable"); + } + if (hasPii) { + imports.add("org.elasticsoftware.akces.annotations.PIIData"); + } + + collectFieldTypeImports(config.stateFields(), imports); + + for (String imp : imports) { + sb.append("import ").append(imp).append(";\n"); + } + sb.append("\n"); + + // Annotation + sb.append("@AggregateStateInfo(type = \"").append(aggregateName).append("\", version = ") + .append(config.stateVersion()).append(")\n"); + + // Record declaration + sb.append("public record ").append(className).append("(\n"); + + List fields = config.stateFields(); + for (int i = 0; i < fields.size(); i++) { + Field field = fields.get(i); + sb.append(" "); + appendFieldAnnotations(sb, field); + sb.append(mapType(field.type())).append(" ").append(field.name()); + if (i < fields.size() - 1) { + sb.append(",\n"); + } else { + sb.append("\n"); + } + } + + sb.append(") implements AggregateState {\n"); + + // getAggregateId method + String idFieldName = findIdFieldName(fields); + sb.append(" @Override\n"); + sb.append(" public String getAggregateId() {\n"); + sb.append(" return ").append(idFieldName).append("();\n"); + sb.append(" }\n"); + sb.append("}\n"); + + String relativePath = aggregatePackage.replace('.', '/') + "/" + className + ".java"; + return new GeneratedFile(relativePath, sb.toString()); + } + + // --- Aggregate class generation --- + + private GeneratedFile generateAggregateClass( + String aggregatePackage, + String aggregateName, + AggregateConfig config, + List slices, + Map allCommands, + Map allSuccessEvents, + Map allErrorEvents) { + + String stateClassName = aggregateName + "State"; + String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1); + + StringBuilder sb = new StringBuilder(); + sb.append(LICENSE_HEADER).append("\n"); + sb.append("package ").append(aggregatePackage).append(";\n\n"); + + // Collect imports + Set imports = new TreeSet<>(); + imports.add("org.elasticsoftware.akces.aggregate.Aggregate"); + imports.add("org.elasticsoftware.akces.annotations.AggregateInfo"); + imports.add("org.elasticsoftware.akces.annotations.CommandHandler"); + imports.add("org.elasticsoftware.akces.annotations.EventSourcingHandler"); + imports.add("org.elasticsoftware.akces.events.DomainEvent"); + imports.add("jakarta.validation.constraints.NotNull"); + imports.add("java.util.stream.Stream"); + + // Import all command classes + for (String cmdTitle : allCommands.keySet()) { + imports.add(aggregatePackage + ".commands." + cmdTitle + "Command"); + } + + // Import all event classes + for (String evtTitle : allSuccessEvents.keySet()) { + imports.add(aggregatePackage + ".events." + evtTitle + "Event"); + } + for (String evtTitle : allErrorEvents.keySet()) { + imports.add(aggregatePackage + ".events." + evtTitle + "Event"); + } + + for (String imp : imports) { + sb.append("import ").append(imp).append(";\n"); + } + sb.append("\n"); + + // Aggregate annotation + sb.append("@AggregateInfo(\n"); + sb.append(" value = \"").append(aggregateName).append("\",\n"); + sb.append(" stateClass = ").append(stateClassName).append(".class"); + if (config != null && config.generateGDPRKeyOnCreate()) { + sb.append(",\n generateGDPRKeyOnCreate = true"); + } + if (config != null && config.indexed()) { + sb.append(",\n indexed = true"); + if (config.indexName() != null) { + sb.append(",\n indexName = \"").append(config.indexName()).append("\""); + } + } + sb.append(")\n"); + + // Class declaration + sb.append("public final class ").append(aggregateName).append(" implements Aggregate<").append(stateClassName).append("> {\n\n"); + + // getName() method + sb.append(" @Override\n"); + sb.append(" public String getName() {\n"); + sb.append(" return \"").append(aggregateName).append("\";\n"); + sb.append(" }\n\n"); + + // getStateClass() method + sb.append(" @Override\n"); + sb.append(" public Class<").append(stateClassName).append("> getStateClass() {\n"); + sb.append(" return ").append(stateClassName).append(".class;\n"); + sb.append(" }\n"); + + // Generate command handlers and event sourcing handlers per slice + Set generatedCommandHandlers = new LinkedHashSet<>(); + Set generatedEventSourcingHandlers = new LinkedHashSet<>(); + + for (SliceData sliceData : slices) { + for (Element cmd : sliceData.commands()) { + if (generatedCommandHandlers.contains(cmd.title())) { + continue; + } + generatedCommandHandlers.add(cmd.title()); + + // Find events produced by this command (events in the same slice) + List successEvts = sliceData.successEvents(); + List producesEvents = successEvts.stream() + .map(e -> e.title() + "Event.class") + .toList(); + List errorEventNames = sliceData.errorEvents().stream() + .map(e -> e.title() + "Event.class") + .toList(); + + boolean creates = cmd.createsAggregate() != null && cmd.createsAggregate(); + sb.append("\n"); + sb.append(generateCommandHandler( + aggregateName, stateClassName, cmd, creates, producesEvents, errorEventNames, successEvts)); + } + + // Generate event sourcing handlers for success events + for (Element evt : sliceData.successEvents()) { + if (generatedEventSourcingHandlers.contains(evt.title())) { + continue; + } + generatedEventSourcingHandlers.add(evt.title()); + + boolean creates = evt.createsAggregate() != null && evt.createsAggregate(); + sb.append("\n"); + sb.append(generateEventSourcingHandler(stateClassName, evt, creates, config)); + } + } + + sb.append("}\n"); + + String relativePath = aggregatePackage.replace('.', '/') + "/" + aggregateName + ".java"; + return new GeneratedFile(relativePath, sb.toString()); + } + + private String generateCommandHandler( + String aggregateName, + String stateClassName, + Element command, + boolean creates, + List producesEvents, + List errorEvents, + List successEventElements) { + + StringBuilder sb = new StringBuilder(); + String cmdClassName = command.title() + "Command"; + String methodName = creates ? "create" : toCamelCase(command.title()); + + // Annotation + sb.append(" @CommandHandler("); + if (creates) { + sb.append("create = true, "); + } + sb.append("produces = "); + if (producesEvents.size() == 1) { + sb.append(producesEvents.getFirst()); + } else { + sb.append("{").append(String.join(", ", producesEvents)).append("}"); + } + sb.append(", errors = {"); + sb.append(String.join(", ", errorEvents)); + sb.append("})\n"); + + // Method signature + sb.append(" public @NotNull Stream ").append(methodName).append("("); + sb.append("@NotNull ").append(cmdClassName).append(" cmd, "); + if (creates) { + sb.append(stateClassName).append(" isNull"); + } else { + sb.append("@NotNull ").append(stateClassName).append(" currentState"); + } + sb.append(") {\n"); + sb.append(" // Command handler for aggregate: ").append(aggregateName).append("\n"); + + // Method body - generate event construction + if (producesEvents.size() == 1 && successEventElements.size() == 1) { + Element event = successEventElements.getFirst(); + String eventClassName = producesEvents.getFirst().replace(".class", ""); + sb.append(" // TODO: Implement command handler logic\n"); + sb.append(" return Stream.of(new ").append(eventClassName).append("("); + sb.append(generateEventConstructorArgsFromCommand(command, event)); + sb.append("));\n"); + } else { + sb.append(" // TODO: Implement command handler logic\n"); + sb.append(" return Stream.of(\n"); + for (int i = 0; i < producesEvents.size(); i++) { + String eventClassName = producesEvents.get(i).replace(".class", ""); + sb.append(" new ").append(eventClassName).append("(/* TODO */)"); + if (i < producesEvents.size() - 1) { + sb.append(",\n"); + } else { + sb.append("\n"); + } + } + sb.append(" );\n"); + } + sb.append(" }\n"); + + return sb.toString(); + } + + private String generateEventSourcingHandler( + String stateClassName, + Element event, + boolean creates, + AggregateConfig config) { + + StringBuilder sb = new StringBuilder(); + String eventClassName = event.title() + "Event"; + String methodName = creates ? "create" : toCamelCase(event.title()); + + // Annotation + sb.append(" @EventSourcingHandler"); + if (creates) { + sb.append("(create = true)"); + } + sb.append("\n"); + + // Method signature + sb.append(" public @NotNull ").append(stateClassName).append(" ").append(methodName).append("("); + sb.append("@NotNull ").append(eventClassName).append(" event, "); + if (creates) { + sb.append(stateClassName).append(" isNull"); + } else { + sb.append("@NotNull ").append(stateClassName).append(" state"); + } + sb.append(") {\n"); + + // Method body + if (creates && config != null) { + sb.append(" return new ").append(stateClassName).append("("); + sb.append(generateStateConstructorArgsFromEvent(config, event)); + sb.append(");\n"); + } else { + sb.append(" // TODO: Implement state transition logic\n"); + sb.append(" return state;\n"); + } + sb.append(" }\n"); + + return sb.toString(); + } + + // --- Helper methods --- + + /** + * Generates event constructor arguments by mapping event fields from command fields. + * Fields present in both command and event are mapped as {@code cmd.fieldName()}. + * Fields in the event but not in the command get a typed default value placeholder. + */ + private String generateEventConstructorArgsFromCommand(Element command, Element event) { + Set commandFieldNames = command.fields().stream() + .map(Field::name) + .collect(Collectors.toSet()); + + return event.fields().stream() + .map(eventField -> { + if (commandFieldNames.contains(eventField.name())) { + return "cmd." + eventField.name() + "()"; + } else { + // Generate a typed default for fields not available from the command + return getDefaultValue(eventField); + } + }) + .collect(Collectors.joining(", ")); + } + + /** + * Returns a sensible default value expression for a field type to use in scaffolding code. + */ + private String getDefaultValue(Field field) { + String type = field.type(); + if (type == null) { + return "null /* TODO: " + field.name() + " */"; + } + return switch (type) { + case "String", "UUID" -> "null /* TODO: " + field.name() + " */"; + case "Boolean" -> "false /* TODO: " + field.name() + " */"; + case "Double" -> "0.0 /* TODO: " + field.name() + " */"; + case "Decimal" -> "java.math.BigDecimal.ZERO /* TODO: " + field.name() + " */"; + case "Long" -> "0L /* TODO: " + field.name() + " */"; + case "Int" -> "0 /* TODO: " + field.name() + " */"; + default -> "null /* TODO: " + field.name() + " */"; + }; + } + + private String generateStateConstructorArgsFromEvent(AggregateConfig config, Element event) { + List stateFields = config.stateFields(); + Set eventFieldNames = event.fields().stream() + .map(Field::name) + .collect(Collectors.toSet()); + + return stateFields.stream() + .map(sf -> { + if (eventFieldNames.contains(sf.name())) { + return "event." + sf.name() + "()"; + } else { + return "null /* " + sf.name() + " */"; + } + }) + .collect(Collectors.joining(", ")); + } + + private void appendFieldAnnotations(StringBuilder sb, Field field) { + if (field.isIdAttribute()) { + sb.append("@AggregateIdentifier "); + } + if (field.isPiiData()) { + sb.append("@PIIData "); + } + if (field.isOptional()) { + sb.append("@Nullable "); + } else { + sb.append("@NotNull "); + } + } + + private void collectFieldTypeImports(List fields, Set imports) { + for (Field field : fields) { + String javaType = mapFieldType(field); + if (javaType.equals("BigDecimal")) { + imports.add("java.math.BigDecimal"); + } else if (javaType.equals("LocalDate")) { + imports.add("java.time.LocalDate"); + } else if (javaType.equals("LocalDateTime")) { + imports.add("java.time.LocalDateTime"); + } + if (field.isList()) { + imports.add("java.util.List"); + } + if (!field.subfields().isEmpty()) { + collectFieldTypeImports(field.subfields(), imports); + } + } + } + + /** + * Maps an event-modeling field type to a Java type, handling list cardinality. + */ + static String mapFieldType(Field field) { + String baseType = mapType(field.type()); + if (field.isList()) { + return "List<" + baseType + ">"; + } + return baseType; + } + + /** + * Maps an event-modeling type name to a Java type. + */ + static String mapType(String type) { + if (type == null) { + throw new IllegalArgumentException("Field type must not be null"); + } + return switch (type) { + case "String", "UUID" -> "String"; + case "Boolean" -> "Boolean"; + case "Double" -> "Double"; + case "Decimal" -> "BigDecimal"; + case "Long" -> "Long"; + case "Int" -> "Integer"; + case "Date" -> "LocalDate"; + case "DateTime" -> "LocalDateTime"; + case "Custom" -> "Object"; + default -> throw new IllegalArgumentException("Unknown field type: " + type); + }; + } + + private String findIdFieldName(List fields) { + return fields.stream() + .filter(Field::isIdAttribute) + .map(Field::name) + .findFirst() + .orElse(fields.isEmpty() ? "id" : fields.getFirst().name()); + } + + private String toCamelCase(String title) { + if (title == null || title.isEmpty()) return title; + // Convert PascalCase title like "CreditWallet" to camelCase "creditWallet" + return title.substring(0, 1).toLowerCase() + title.substring(1); + } + + /** + * Generates all files to the given output directory from a JSON input stream. + */ + public List generateFromStream(InputStream inputStream, Path outputDir) throws IOException { + EventModelDefinition definition = parse(inputStream); + return generateToDirectory(definition, outputDir); + } +} diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/GeneratedFile.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/GeneratedFile.java new file mode 100644 index 00000000..288a2671 --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/GeneratedFile.java @@ -0,0 +1,27 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen; + +/** + * Represents a generated Java source file. + * + * @param relativePath the relative file path (e.g., "account/commands/CreateAccountCommand.java") + * @param content the complete Java source code content + */ +public record GeneratedFile(String relativePath, String content) { +} diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/AggregateConfig.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/AggregateConfig.java new file mode 100644 index 00000000..2e7e3971 --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/AggregateConfig.java @@ -0,0 +1,49 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * Akces-specific configuration for an aggregate, complementing the event-modeling slices. + * + * @param indexed whether the aggregate is indexed + * @param indexName the index name for the aggregate + * @param generateGDPRKeyOnCreate whether to generate a GDPR key on create + * @param stateVersion the version of the aggregate state + * @param stateFields the fields of the aggregate state + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record AggregateConfig( + boolean indexed, + String indexName, + boolean generateGDPRKeyOnCreate, + int stateVersion, + List stateFields +) { + public AggregateConfig { + if (stateVersion <= 0) { + stateVersion = 1; + } + if (stateFields == null) { + stateFields = List.of(); + } + } +} diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Dependency.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Dependency.java new file mode 100644 index 00000000..92a5fb4f --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Dependency.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Represents a dependency between elements in the event-modeling schema. + * + * @param id the ID of the dependent element + * @param type INBOUND or OUTBOUND + * @param title the title of the dependent element + * @param elementType the type of the dependent element (EVENT, COMMAND, READMODEL, etc.) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Dependency( + String id, + String type, + String title, + String elementType +) { +} diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Element.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Element.java new file mode 100644 index 00000000..8199eea2 --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Element.java @@ -0,0 +1,67 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * Represents an element in an event-modeling slice (command, event, readmodel, etc.). + * + * @param id unique identifier + * @param title human-readable title (used as the basis for class naming) + * @param type element type (COMMAND, EVENT, READMODEL, SCREEN, AUTOMATION) + * @param fields the fields of this element + * @param aggregate the aggregate this element belongs to + * @param createsAggregate whether this element creates a new aggregate instance + * @param tags tags for metadata (e.g., "error" for error events) + * @param description optional description + * @param dependencies dependencies on other elements + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Element( + String id, + String title, + String type, + List fields, + String aggregate, + Boolean createsAggregate, + List tags, + String description, + List dependencies +) { + public Element { + if (fields == null) { + fields = List.of(); + } + if (tags == null) { + tags = List.of(); + } + if (dependencies == null) { + dependencies = List.of(); + } + } + + /** + * Returns true if this element is tagged as an error event. + */ + public boolean isError() { + return tags.contains("error"); + } +} diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/EventModelDefinition.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/EventModelDefinition.java new file mode 100644 index 00000000..c56c3907 --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/EventModelDefinition.java @@ -0,0 +1,39 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; +import java.util.Map; + +/** + * Root model for the Akces code generator input. + * Combines event-modeling slices with Akces-specific aggregate configuration. + * + * @param packageName the base Java package for generated code + * @param aggregateConfig per-aggregate configuration (keyed by aggregate name) + * @param slices event-modeling slices defining commands, events, and their relationships + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record EventModelDefinition( + String packageName, + Map aggregateConfig, + List slices +) { +} diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java new file mode 100644 index 00000000..978388bc --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java @@ -0,0 +1,82 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * Represents a field definition from the event-modeling schema. + * + * @param name the field name + * @param type the field type (String, Boolean, Decimal, Long, Int, etc.) + * @param optional whether the field is optional (nullable) + * @param idAttribute whether this field is the aggregate identifier + * @param piiData whether this field contains PII data requiring GDPR protection + * @param cardinality Single or List + * @param subfields nested fields for Custom types + * @param technicalAttribute whether this is a technical attribute (not business relevant) + * @param generated whether this field value is generated + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Field( + String name, + String type, + Boolean optional, + Boolean idAttribute, + Boolean piiData, + String cardinality, + List subfields, + Boolean technicalAttribute, + Boolean generated +) { + public Field { + if (subfields == null) { + subfields = List.of(); + } + } + + /** + * Returns true if this field is the aggregate identifier. + */ + public boolean isIdAttribute() { + return idAttribute != null && idAttribute; + } + + /** + * Returns true if this field contains PII data requiring GDPR protection. + */ + public boolean isPiiData() { + return piiData != null && piiData; + } + + /** + * Returns true if this field is optional (nullable). + */ + public boolean isOptional() { + return optional != null && optional; + } + + /** + * Returns true if this field is a list. + */ + public boolean isList() { + return "List".equals(cardinality); + } +} diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java new file mode 100644 index 00000000..2a735771 --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java @@ -0,0 +1,61 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Represents an event-modeling slice, which groups related commands and events + * that form a coherent use case or business operation. + * + * @param id unique identifier for the slice + * @param title human-readable title + * @param sliceType the type of slice (STATE_CHANGE, STATE_VIEW, AUTOMATION) + * @param commands commands in this slice + * @param events events in this slice + * @param externalEvents external events from other aggregates consumed in this slice + * @param aggregates aggregate names referenced by this slice + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Slice( + String id, + String title, + String sliceType, + List commands, + List events, + @JsonProperty("external_events") List externalEvents, + List aggregates +) { + public Slice { + if (commands == null) { + commands = List.of(); + } + if (events == null) { + events = List.of(); + } + if (externalEvents == null) { + externalEvents = List.of(); + } + if (aggregates == null) { + aggregates = List.of(); + } + } +} diff --git a/main/codegen/src/main/resources/akces-event-model.schema.json b/main/codegen/src/main/resources/akces-event-model.schema.json new file mode 100644 index 00000000..4d7de2c0 --- /dev/null +++ b/main/codegen/src/main/resources/akces-event-model.schema.json @@ -0,0 +1,330 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Akces Event Model Definition", + "description": "Extends the event-modeling specification (https://github.com/dilgerma/event-modeling-spec) with Akces Framework-specific aggregate configuration for code generation.", + "type": "object", + "properties": { + "packageName": { + "type": "string", + "description": "The base Java package for generated code" + }, + "aggregateConfig": { + "type": "object", + "description": "Per-aggregate configuration keyed by aggregate name", + "additionalProperties": { "$ref": "#/$defs/AggregateConfig" } + }, + "slices": { + "type": "array", + "items": { "$ref": "#/$defs/Slice" } + } + }, + "required": ["packageName", "aggregateConfig", "slices"], + "additionalProperties": false, + + "$defs": { + "AggregateConfig": { + "type": "object", + "description": "Akces-specific configuration for an aggregate", + "properties": { + "indexed": { + "type": "boolean", + "description": "Whether the aggregate is indexed" + }, + "indexName": { + "type": "string", + "description": "The index name for the aggregate" + }, + "generateGDPRKeyOnCreate": { + "type": "boolean", + "description": "Whether to generate a GDPR key on create" + }, + "stateVersion": { + "type": "integer", + "minimum": 1, + "description": "The version of the aggregate state" + }, + "stateFields": { + "type": "array", + "items": { "$ref": "#/$defs/Field" }, + "description": "The fields of the aggregate state" + } + }, + "required": ["stateFields"], + "additionalProperties": false + }, + + "Slice": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "status": { + "type": "string", + "enum": ["Created", "Done", "InProgress"] + }, + "index": { "type": "integer" }, + "title": { "type": "string" }, + "context": { "type": "string" }, + "sliceType": { + "type": "string", + "enum": ["STATE_CHANGE", "STATE_VIEW", "AUTOMATION"] + }, + "commands": { + "type": "array", + "items": { "$ref": "#/$defs/Element" } + }, + "events": { + "type": "array", + "items": { "$ref": "#/$defs/Element" } + }, + "external_events": { + "type": "array", + "items": { "$ref": "#/$defs/Element" } + }, + "readmodels": { + "type": "array", + "items": { "$ref": "#/$defs/Element" } + }, + "screens": { + "type": "array", + "items": { "$ref": "#/$defs/Element" } + }, + "screenImages": { + "type": "array", + "items": { "$ref": "#/$defs/ScreenImage" } + }, + "processors": { + "type": "array", + "items": { "$ref": "#/$defs/Element" } + }, + "tables": { + "type": "array", + "items": { "$ref": "#/$defs/Table" } + }, + "specifications": { + "type": "array", + "items": { "$ref": "#/$defs/Specification" } + }, + "actors": { + "type": "array", + "items": { "$ref": "#/$defs/Actor" } + }, + "aggregates": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": [ + "id", + "title", + "sliceType", + "commands", + "events", + "readmodels", + "screens", + "processors", + "tables", + "specifications" + ], + "additionalProperties": false + }, + + "Element": { + "type": "object", + "properties": { + "groupId": { "type": "string" }, + "id": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "domain": { "type": "string" }, + "modelContext": { "type": "string" }, + "context": { "type": "string", "enum": ["INTERNAL", "EXTERNAL"] }, + "slice": { "type": "string" }, + "title": { "type": "string" }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/Field" } + }, + "type": { + "type": "string", + "enum": ["COMMAND", "EVENT", "READMODEL", "SCREEN", "AUTOMATION"] + }, + "description": { "type": "string" }, + "aggregate": { "type": "string" }, + "aggregateDependencies": { + "type": "array", + "items": { "type": "string" } + }, + "dependencies": { + "type": "array", + "items": { "$ref": "#/$defs/Dependency" } + }, + "apiEndpoint": { "type": "string" }, + "service": { "type": ["string", "null"] }, + "createsAggregate": { "type": "boolean" }, + "triggers": { "type": "array", "items": { "type": "string" } }, + "sketched": { "type": "boolean" }, + "prototype": { "type": "object" }, + "listElement": { "type": "boolean" } + }, + "required": ["id", "title", "fields", "type", "dependencies"], + "additionalProperties": false + }, + + "ScreenImage": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "url": { "type": "string" } + }, + "required": ["id", "title"], + "additionalProperties": false + }, + + "Table": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/Field" } + } + }, + "required": ["id", "title", "fields"], + "additionalProperties": false + }, + + "Specification": { + "type": "object", + "properties": { + "vertical": { "type": "boolean" }, + "id": { "type": "string" }, + "sliceName": { "type": "string" }, + "title": { "type": "string" }, + "given": { + "type": "array", + "items": { "$ref": "#/$defs/SpecificationStep" } + }, + "when": { + "type": "array", + "items": { "$ref": "#/$defs/SpecificationStep" } + }, + "then": { + "type": "array", + "items": { "$ref": "#/$defs/SpecificationStep" } + }, + "comments": { + "type": "array", + "items": { "$ref": "#/$defs/Comment" } + }, + "linkedId": { "type": "string" } + }, + "required": ["id", "title", "given", "when", "then", "linkedId"], + "additionalProperties": false + }, + + "SpecificationStep": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "examples": { "type": "array", "items": { "type": "object" } }, + "id": { "type": "string" }, + "index": { "type": "integer" }, + "specRow": { "type": "integer" }, + "type": { + "type": "string", + "enum": ["SPEC_EVENT", "SPEC_COMMAND", "SPEC_READMODEL", "SPEC_ERROR"] + }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/Field" } + }, + "linkedId": { "type": "string" }, + "expectEmptyList": { "type": "boolean" } + }, + "required": ["title", "id", "type"], + "additionalProperties": false + }, + + "Comment": { + "type": "object", + "properties": { "description": { "type": "string" } }, + "required": ["description"], + "additionalProperties": false + }, + + "Actor": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "authRequired": { "type": "boolean" } + }, + "required": ["name", "authRequired"], + "additionalProperties": false + }, + + "Dependency": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "type": "string", "enum": ["INBOUND", "OUTBOUND"] }, + "title": { "type": "string" }, + "elementType": { + "type": "string", + "enum": ["EVENT", "COMMAND", "READMODEL", "SCREEN", "AUTOMATION"] + } + }, + "required": ["id", "type", "title", "elementType"], + "additionalProperties": false + }, + + "Field": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { + "type": "string", + "enum": [ + "String", + "Boolean", + "Double", + "Decimal", + "Long", + "Custom", + "Date", + "DateTime", + "UUID", + "Int" + ] + }, + "example": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ] + }, + "subfields": { + "type": "array", + "items": { "$ref": "#/$defs/Field" } + }, + "mapping": { "type": "string" }, + "optional": { "type": "boolean" }, + "piiData": { + "type": "boolean", + "description": "Whether this field contains PII data requiring GDPR protection" + }, + "technicalAttribute": { "type": "boolean" }, + "generated": { "type": "boolean" }, + "idAttribute": { "type": "boolean" }, + "schema": { "type": "string" }, + "cardinality": { + "type": "string", + "enum": ["List", "Single"] + } + }, + "required": ["name", "type"], + "additionalProperties": false + } + } +} diff --git a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java new file mode 100644 index 00000000..30fd89cf --- /dev/null +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java @@ -0,0 +1,169 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen; + +import org.elasticsoftware.akces.codegen.model.EventModelDefinition; +import org.testng.annotations.Test; + +import javax.tools.*; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.testng.Assert.*; + +/** + * Tests that verify the generated Java source code actually compiles. + * Uses the Java Compiler API ({@link javax.tools.JavaCompiler}) to compile + * the generated files with the Akces API on the classpath. + *

+ * Generated sources are written to {@code target/generated-test-sources} + * which is managed by Maven (cleaned by {@code mvn clean}). + */ +public class CompilationTest { + + private static final Path GENERATED_SOURCES_DIR = Path.of("target", "generated-test-sources"); + private static final Path TEST_CLASSES_DIR = Path.of("target", "test-classes"); + + private final AkcesCodeGenerator generator = new AkcesCodeGenerator("com.example.aggregates"); + + @Test + public void testAccountGeneratedCodeCompiles() throws Exception { + EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); + assertCompiles(definition, "Account"); + } + + @Test + public void testWalletGeneratedCodeCompiles() throws Exception { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + assertCompiles(definition, "Wallet"); + } + + @Test + public void testBothAggregatesCompileTogether() throws Exception { + // Generate and compile both definitions into the same source tree + // to verify there are no naming collisions + EventModelDefinition accountDef = loadDefinition("crypto-trading-account.json"); + EventModelDefinition walletDef = loadDefinition("crypto-trading-wallet.json"); + + Path outputDir = GENERATED_SOURCES_DIR; + Files.createDirectories(outputDir); + + generator.generateToDirectory(accountDef, outputDir); + generator.generateToDirectory(walletDef, outputDir); + + List sourceFiles = collectJavaFiles(outputDir); + assertFalse(sourceFiles.isEmpty(), "Should have generated Java source files"); + + compileAndAssert(sourceFiles, outputDir); + } + + /** + * Generates code from the definition, writes it to + * {@code target/generated-test-sources}, + * and compiles it using the Java Compiler API. + */ + private void assertCompiles(EventModelDefinition definition, String aggregateName) throws Exception { + Path outputDir = GENERATED_SOURCES_DIR; + Files.createDirectories(outputDir); + + List generated = generator.generateToDirectory(definition, outputDir); + assertFalse(generated.isEmpty(), + "Should have generated files for " + aggregateName); + + // Verify all generated files exist on disk + for (GeneratedFile gf : generated) { + Path filePath = outputDir.resolve(gf.relativePath()); + assertTrue(Files.exists(filePath), + "Generated file should exist: " + gf.relativePath()); + } + + List sourceFiles = collectJavaFiles(outputDir); + assertFalse(sourceFiles.isEmpty(), "Should have .java files on disk"); + + compileAndAssert(sourceFiles, outputDir); + } + + /** + * Compiles the given Java source files and asserts that compilation succeeds. + * Compiled classes are written to {@code target/test-classes}. + */ + private void compileAndAssert(List sourceFiles, Path outputDir) throws Exception { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "Java compiler not available (requires JDK, not JRE)"); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null)) { + // Set the classpath to include the test classpath (which has akces-api and its transitive deps) + String classpath = System.getProperty("java.class.path"); + + // Compile into the Maven test-classes directory + Files.createDirectories(TEST_CLASSES_DIR); + + List options = List.of( + "-classpath", classpath, + "-d", TEST_CLASSES_DIR.toString() + ); + + Iterable compilationUnits = fileManager.getJavaFileObjectsFromPaths(sourceFiles); + JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, diagnostics, options, null, compilationUnits); + + Boolean success = task.call(); + + // Collect error diagnostics for the failure message + if (!success) { + StringBuilder errorMsg = new StringBuilder("Compilation failed:\n"); + for (Diagnostic diagnostic : diagnostics.getDiagnostics()) { + if (diagnostic.getKind() == Diagnostic.Kind.ERROR) { + errorMsg.append(" ").append(diagnostic.getSource() != null ? diagnostic.getSource().getName() : "unknown") + .append(":").append(diagnostic.getLineNumber()) + .append(": ").append(diagnostic.getMessage(null)) + .append("\n"); + } + } + // Also print the generated source files for debugging + errorMsg.append("\nGenerated source files:\n"); + for (Path sourceFile : sourceFiles) { + errorMsg.append(" ").append(sourceFile.getFileName()).append(":\n"); + errorMsg.append(Files.readString(sourceFile)).append("\n"); + } + fail(errorMsg.toString()); + } + } + } + + /** + * Recursively collects all .java files under the given directory. + */ + private List collectJavaFiles(Path dir) throws Exception { + List javaFiles = new ArrayList<>(); + try (var stream = Files.walk(dir)) { + stream.filter(p -> p.getFileName().toString().endsWith(".java")) + .forEach(javaFiles::add); + } + return javaFiles; + } + + private EventModelDefinition loadDefinition(String resourceName) { + InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName); + assertNotNull(is, "Test resource not found: " + resourceName); + return generator.parse(is); + } +} diff --git a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java new file mode 100644 index 00000000..28041baa --- /dev/null +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java @@ -0,0 +1,458 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen; + +import org.elasticsoftware.akces.codegen.model.EventModelDefinition; +import org.testng.annotations.Test; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.testng.Assert.*; + +/** + * Tests the Akces code generator against the crypto-trading test app definitions. + * Verifies that the generated code structurally matches the actual crypto-trading + * aggregate implementations. + */ +public class CryptoTradingCodeGenTest { + + private final AkcesCodeGenerator generator = new AkcesCodeGenerator("com.example.aggregates"); + + // --- Account Aggregate Tests --- + + @Test + public void testParseAccountDefinition() { + EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); + + assertNotNull(definition); + assertEquals(definition.packageName(), "org.elasticsoftware.cryptotrading.aggregates"); + assertEquals(definition.slices().size(), 1); + assertNotNull(definition.aggregateConfig()); + assertTrue(definition.aggregateConfig().containsKey("Account")); + } + + @Test + public void testGenerateAccountFiles() { + EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); + List files = generator.generate(definition); + + assertNotNull(files); + assertFalse(files.isEmpty()); + + Map fileMap = files.stream() + .collect(Collectors.toMap(GeneratedFile::relativePath, f -> f)); + + // Verify expected files are generated + assertTrue(fileMap.containsKey("com/example/aggregates/account/commands/CreateAccountCommand.java"), + "Should generate CreateAccountCommand.java"); + assertTrue(fileMap.containsKey("com/example/aggregates/account/events/AccountCreatedEvent.java"), + "Should generate AccountCreatedEvent.java"); + assertTrue(fileMap.containsKey("com/example/aggregates/account/AccountState.java"), + "Should generate AccountState.java"); + assertTrue(fileMap.containsKey("com/example/aggregates/account/Account.java"), + "Should generate Account.java"); + + assertEquals(files.size(), 4, "Should generate exactly 4 files for Account aggregate"); + } + + @Test + public void testGeneratedAccountCommand() { + EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); + List files = generator.generate(definition); + + GeneratedFile commandFile = findFile(files, "com/example/aggregates/account/commands/CreateAccountCommand.java"); + assertNotNull(commandFile); + + String content = commandFile.content(); + + // Verify structure matches actual CreateAccountCommand + assertTrue(content.contains("@CommandInfo(type = \"CreateAccount\""), + "Should have correct CommandInfo annotation"); + assertTrue(content.contains("public record CreateAccountCommand("), + "Should be a record"); + assertTrue(content.contains("implements Command"), + "Should implement Command"); + assertTrue(content.contains("@AggregateIdentifier"), + "Should have AggregateIdentifier on id field"); + assertTrue(content.contains("@NotNull String userId"), + "Should have userId field"); + assertTrue(content.contains("@NotNull String country"), + "Should have country field"); + assertTrue(content.contains("@PIIData @NotNull String firstName"), + "Should have PIIData annotation on firstName field"); + assertTrue(content.contains("@PIIData @NotNull String lastName"), + "Should have PIIData annotation on lastName field"); + assertTrue(content.contains("@PIIData @NotNull String email"), + "Should have PIIData annotation on email field"); + assertTrue(content.contains("return userId()"), + "Should return userId as aggregate ID"); + } + + @Test + public void testGeneratedAccountEvent() { + EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); + List files = generator.generate(definition); + + GeneratedFile eventFile = findFile(files, "com/example/aggregates/account/events/AccountCreatedEvent.java"); + assertNotNull(eventFile); + + String content = eventFile.content(); + + // Verify structure matches actual AccountCreatedEvent + assertTrue(content.contains("@DomainEventInfo(type = \"AccountCreated\""), + "Should have correct DomainEventInfo annotation"); + assertTrue(content.contains("public record AccountCreatedEvent("), + "Should be a record"); + assertTrue(content.contains("implements DomainEvent"), + "Should implement DomainEvent"); + assertTrue(content.contains("@AggregateIdentifier"), + "Should have AggregateIdentifier on id field"); + assertTrue(content.contains("@NotNull String userId"), + "Should have userId field"); + assertTrue(content.contains("@PIIData @NotNull String firstName"), + "Should have PIIData annotation on firstName field"); + assertTrue(content.contains("@PIIData @NotNull String lastName"), + "Should have PIIData annotation on lastName field"); + assertTrue(content.contains("@PIIData @NotNull String email"), + "Should have PIIData annotation on email field"); + assertTrue(content.contains("return userId()"), + "Should return userId as aggregate ID"); + } + + @Test + public void testGeneratedAccountState() { + EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); + List files = generator.generate(definition); + + GeneratedFile stateFile = findFile(files, "com/example/aggregates/account/AccountState.java"); + assertNotNull(stateFile); + + String content = stateFile.content(); + + // Verify structure matches actual AccountState + assertTrue(content.contains("@AggregateStateInfo(type = \"Account\", version = 1)"), + "Should have correct AggregateStateInfo annotation"); + assertTrue(content.contains("public record AccountState("), + "Should be a record"); + assertTrue(content.contains("implements AggregateState"), + "Should implement AggregateState"); + assertTrue(content.contains("@PIIData"), + "Should have PIIData annotation on PII fields"); + assertTrue(content.contains("@NotNull String userId"), + "Should have userId field"); + assertTrue(content.contains("@NotNull String country"), + "Should have country field"); + assertTrue(content.contains("return userId()"), + "Should return userId as aggregate ID"); + + // Count PIIData annotations - should be 3 (firstName, lastName, email) + int piiCount = content.split("@PIIData", -1).length - 1; + assertEquals(piiCount, 3, "Should have 3 PIIData annotations"); + } + + @Test + public void testGeneratedAccountAggregate() { + EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); + List files = generator.generate(definition); + + GeneratedFile aggregateFile = findFile(files, "com/example/aggregates/account/Account.java"); + assertNotNull(aggregateFile); + + String content = aggregateFile.content(); + + // Verify structure matches actual Account aggregate + assertTrue(content.contains("@AggregateInfo("), + "Should have AggregateInfo annotation"); + assertTrue(content.contains("value = \"Account\""), + "Should have correct aggregate name"); + assertTrue(content.contains("stateClass = AccountState.class"), + "Should reference state class"); + assertTrue(content.contains("generateGDPRKeyOnCreate = true"), + "Should have GDPR key generation enabled"); + assertTrue(content.contains("indexed = true"), + "Should be indexed"); + assertTrue(content.contains("indexName = \"Users\""), + "Should have correct index name"); + assertTrue(content.contains("public final class Account implements Aggregate"), + "Should implement Aggregate with correct state type"); + assertTrue(content.contains("@CommandHandler(create = true"), + "Should have create command handler"); + assertTrue(content.contains("produces = AccountCreatedEvent.class"), + "Should produce AccountCreatedEvent"); + assertTrue(content.contains("@EventSourcingHandler(create = true)"), + "Should have create event sourcing handler"); + assertTrue(content.contains("return \"Account\""), + "getName() should return aggregate name"); + assertTrue(content.contains("return AccountState.class"), + "getStateClass() should return state class"); + } + + // --- Wallet Aggregate Tests --- + + @Test + public void testParseWalletDefinition() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + + assertNotNull(definition); + assertEquals(definition.slices().size(), 6); + assertNotNull(definition.aggregateConfig()); + assertTrue(definition.aggregateConfig().containsKey("Wallet")); + } + + @Test + public void testGenerateWalletFiles() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + assertNotNull(files); + assertFalse(files.isEmpty()); + + Map fileMap = files.stream() + .collect(Collectors.toMap(GeneratedFile::relativePath, f -> f)); + + // Commands (6) + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/CreateWalletCommand.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/CreateBalanceCommand.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/CreditWalletCommand.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/DebitWalletCommand.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/ReserveAmountCommand.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/CancelReservationCommand.java")); + + // Success Events (6) + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/WalletCreatedEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/BalanceCreatedEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/WalletCreditedEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/WalletDebitedEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/AmountReservedEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/ReservationCancelledEvent.java")); + + // Error Events (5) + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/BalanceAlreadyExistsErrorEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/InvalidCryptoCurrencyErrorEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/InvalidAmountErrorEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/InsufficientFundsErrorEvent.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/ReservationNotFoundErrorEvent.java")); + + // State + Aggregate class + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/WalletState.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/Wallet.java")); + } + + @Test + public void testGeneratedWalletCreateCommand() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/CreateWalletCommand.java"); + assertNotNull(file); + + String content = file.content(); + assertTrue(content.contains("@CommandInfo(type = \"CreateWallet\"")); + assertTrue(content.contains("public record CreateWalletCommand(")); + assertTrue(content.contains("implements Command")); + assertTrue(content.contains("@AggregateIdentifier")); + assertTrue(content.contains("@NotNull String id")); + assertTrue(content.contains("@NotNull String currency")); + } + + @Test + public void testGeneratedWalletCreditCommand() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/CreditWalletCommand.java"); + assertNotNull(file); + + String content = file.content(); + assertTrue(content.contains("@CommandInfo(type = \"CreditWallet\"")); + assertTrue(content.contains("BigDecimal amount")); + assertTrue(content.contains("import java.math.BigDecimal;")); + } + + @Test + public void testGeneratedWalletCreditedEvent() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/WalletCreditedEvent.java"); + assertNotNull(file); + + String content = file.content(); + assertTrue(content.contains("@DomainEventInfo(type = \"WalletCredited\"")); + assertTrue(content.contains("public record WalletCreditedEvent(")); + assertTrue(content.contains("implements DomainEvent")); + assertTrue(content.contains("BigDecimal amount")); + assertTrue(content.contains("BigDecimal balance")); + } + + @Test + public void testGeneratedInsufficientFundsErrorEvent() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/InsufficientFundsErrorEvent.java"); + assertNotNull(file); + + String content = file.content(); + assertTrue(content.contains("@DomainEventInfo(type = \"InsufficientFundsError\"")); + assertTrue(content.contains("public record InsufficientFundsErrorEvent(")); + assertTrue(content.contains("implements ErrorEvent"), + "Error events should implement ErrorEvent"); + assertTrue(content.contains("@Nullable String referenceId"), + "Optional fields should use @Nullable"); + assertTrue(content.contains("BigDecimal availableAmount")); + assertTrue(content.contains("BigDecimal requestedAmount")); + } + + @Test + public void testGeneratedWalletAggregate() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/Wallet.java"); + assertNotNull(file); + + String content = file.content(); + + // Verify aggregate structure + assertTrue(content.contains("@AggregateInfo(")); + assertTrue(content.contains("value = \"Wallet\"")); + assertTrue(content.contains("stateClass = WalletState.class")); + assertTrue(content.contains("indexed = true")); + assertTrue(content.contains("indexName = \"Users\"")); + assertTrue(content.contains("public final class Wallet implements Aggregate")); + + // Verify command handlers exist + assertTrue(content.contains("@CommandHandler(create = true")); + assertTrue(content.contains("CreateWalletCommand cmd")); + assertTrue(content.contains("CreateBalanceCommand cmd")); + assertTrue(content.contains("CreditWalletCommand cmd")); + assertTrue(content.contains("DebitWalletCommand cmd")); + assertTrue(content.contains("ReserveAmountCommand cmd")); + assertTrue(content.contains("CancelReservationCommand cmd")); + + // Verify event sourcing handlers exist + assertTrue(content.contains("@EventSourcingHandler(create = true)")); + assertTrue(content.contains("WalletCreatedEvent event")); + + // Verify error events are referenced + assertTrue(content.contains("BalanceAlreadyExistsErrorEvent.class")); + assertTrue(content.contains("InsufficientFundsErrorEvent.class")); + assertTrue(content.contains("InvalidCryptoCurrencyErrorEvent.class")); + assertTrue(content.contains("InvalidAmountErrorEvent.class")); + } + + @Test + public void testGeneratedWalletState() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/WalletState.java"); + assertNotNull(file); + + String content = file.content(); + assertTrue(content.contains("@AggregateStateInfo(type = \"Wallet\", version = 1)")); + assertTrue(content.contains("public record WalletState(")); + assertTrue(content.contains("implements AggregateState")); + assertTrue(content.contains("@AggregateIdentifier")); + assertTrue(content.contains("return id()")); + } + + @Test + public void testGeneratedReserveAmountCommand() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/ReserveAmountCommand.java"); + assertNotNull(file); + + String content = file.content(); + assertTrue(content.contains("@CommandInfo(type = \"ReserveAmount\"")); + assertTrue(content.contains("@AggregateIdentifier")); + assertTrue(content.contains("@NotNull String userId")); + assertTrue(content.contains("@NotNull String currency")); + assertTrue(content.contains("@NotNull BigDecimal amount")); + assertTrue(content.contains("@NotNull String referenceId")); + assertTrue(content.contains("return userId()"), + "Should use userId as aggregate ID"); + } + + @Test + public void testGeneratedAmountReservedEvent() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/AmountReservedEvent.java"); + assertNotNull(file); + + String content = file.content(); + assertTrue(content.contains("@DomainEventInfo(type = \"AmountReserved\"")); + assertTrue(content.contains("public record AmountReservedEvent(")); + assertTrue(content.contains("@AggregateIdentifier")); + assertTrue(content.contains("@NotNull String userId")); + assertTrue(content.contains("@NotNull BigDecimal amount")); + assertTrue(content.contains("@NotNull String referenceId")); + } + + // --- File Output Test --- + + @Test + public void testGenerateToDirectory() throws Exception { + EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); + java.nio.file.Path outputDir = java.nio.file.Files.createTempDirectory("akces-codegen-test"); + try { + List files = generator.generateToDirectory(definition, outputDir); + assertEquals(files.size(), 4); + + // Verify files exist on disk + for (GeneratedFile file : files) { + java.nio.file.Path filePath = outputDir.resolve(file.relativePath()); + assertTrue(java.nio.file.Files.exists(filePath), + "File should exist: " + file.relativePath()); + String diskContent = java.nio.file.Files.readString(filePath); + assertEquals(diskContent, file.content(), + "Disk content should match generated content"); + } + } finally { + // Clean up + try (var walk = java.nio.file.Files.walk(outputDir)) { + walk.sorted(java.util.Comparator.reverseOrder()) + .map(java.nio.file.Path::toFile) + .forEach(java.io.File::delete); + } + } + } + + // --- Helper Methods --- + + private EventModelDefinition loadDefinition(String resourceName) { + InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName); + assertNotNull(is, "Test resource not found: " + resourceName); + return generator.parse(is); + } + + private GeneratedFile findFile(List files, String relativePath) { + return files.stream() + .filter(f -> f.relativePath().equals(relativePath)) + .findFirst() + .orElse(null); + } +} diff --git a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java new file mode 100644 index 00000000..f0851c4f --- /dev/null +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java @@ -0,0 +1,376 @@ +/* + * Copyright 2022 - 2026 The Original Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.elasticsoftware.akces.codegen; + +import org.everit.json.schema.Schema; +import org.everit.json.schema.ValidationException; +import org.everit.json.schema.loader.SchemaLoader; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.io.InputStream; + +import static org.testng.Assert.*; + +/** + * Validates that the event-modeling JSON definition files conform to the + * augmented Akces event-model JSON schema. + */ +public class SchemaValidationTest { + + private Schema schema; + + @BeforeClass + public void loadSchema() { + InputStream schemaStream = getClass().getClassLoader() + .getResourceAsStream("akces-event-model.schema.json"); + assertNotNull(schemaStream, "Schema resource not found: akces-event-model.schema.json"); + JSONObject rawSchema = new JSONObject(new JSONTokener(schemaStream)); + schema = SchemaLoader.load(rawSchema); + } + + @Test + public void testAccountDefinitionConformsToSchema() { + JSONObject json = loadJson("crypto-trading-account.json"); + schema.validate(json); + } + + @Test + public void testWalletDefinitionConformsToSchema() { + JSONObject json = loadJson("crypto-trading-wallet.json"); + schema.validate(json); + } + + @Test + public void testMissingRequiredPackageName() { + JSONObject json = new JSONObject() + .put("aggregateConfig", new JSONObject()) + .put("slices", new org.json.JSONArray()); + + ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); + assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("packageName")), + "Should report missing packageName: " + ex.getAllMessages()); + } + + @Test + public void testMissingRequiredSlices() { + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject()); + + ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); + assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("slices")), + "Should report missing slices: " + ex.getAllMessages()); + } + + @Test + public void testMissingRequiredAggregateConfig() { + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("slices", new org.json.JSONArray()); + + ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); + assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("aggregateConfig")), + "Should report missing aggregateConfig: " + ex.getAllMessages()); + } + + @Test + public void testAdditionalPropertiesRejected() { + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject()) + .put("slices", new org.json.JSONArray()) + .put("unknownProperty", "value"); + + assertThrows(ValidationException.class, () -> schema.validate(json)); + } + + @Test + public void testInvalidSliceTypeRejected() { + JSONObject slice = new JSONObject() + .put("id", "test") + .put("title", "Test") + .put("sliceType", "INVALID_TYPE") + .put("commands", new org.json.JSONArray()) + .put("events", new org.json.JSONArray()) + .put("readmodels", new org.json.JSONArray()) + .put("screens", new org.json.JSONArray()) + .put("processors", new org.json.JSONArray()) + .put("tables", new org.json.JSONArray()) + .put("specifications", new org.json.JSONArray()); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject()) + .put("slices", new org.json.JSONArray().put(slice)); + + assertThrows(ValidationException.class, () -> schema.validate(json)); + } + + @Test + public void testInvalidFieldTypeRejected() { + JSONObject field = new JSONObject() + .put("name", "test") + .put("type", "InvalidType"); + + JSONObject element = new JSONObject() + .put("id", "test-cmd") + .put("title", "Test") + .put("type", "COMMAND") + .put("fields", new org.json.JSONArray().put(field)) + .put("dependencies", new org.json.JSONArray()); + + JSONObject slice = new JSONObject() + .put("id", "test") + .put("title", "Test") + .put("sliceType", "STATE_CHANGE") + .put("commands", new org.json.JSONArray().put(element)) + .put("events", new org.json.JSONArray()) + .put("readmodels", new org.json.JSONArray()) + .put("screens", new org.json.JSONArray()) + .put("processors", new org.json.JSONArray()) + .put("tables", new org.json.JSONArray()) + .put("specifications", new org.json.JSONArray()); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject()) + .put("slices", new org.json.JSONArray().put(slice)); + + assertThrows(ValidationException.class, () -> schema.validate(json)); + } + + @Test + public void testInvalidStateFieldTypeRejected() { + JSONObject stateField = new JSONObject() + .put("name", "id") + .put("type", "NotAType"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + assertThrows(ValidationException.class, () -> schema.validate(json)); + } + + @Test + public void testValidMinimalDefinition() { + JSONObject stateField = new JSONObject() + .put("name", "id") + .put("type", "String"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + schema.validate(json); + } + + @Test + public void testStateFieldPiiDataAccepted() { + JSONObject stateField = new JSONObject() + .put("name", "email") + .put("type", "String") + .put("piiData", true) + .put("idAttribute", false) + .put("optional", false); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("User", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + schema.validate(json); + } + + @Test + public void testAggregateConfigWithAllProperties() { + JSONObject stateField = new JSONObject() + .put("name", "id") + .put("type", "String") + .put("idAttribute", true); + + JSONObject aggregateConfig = new JSONObject() + .put("indexed", true) + .put("indexName", "TestIndex") + .put("generateGDPRKeyOnCreate", true) + .put("stateVersion", 1) + .put("stateFields", new org.json.JSONArray().put(stateField)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + schema.validate(json); + } + + @Test + public void testExternalEventsWithValidElementAccepted() { + JSONObject externalEvent = new JSONObject() + .put("id", "account-created-ext") + .put("title", "AccountCreated") + .put("type", "EVENT") + .put("aggregate", "Account") + .put("fields", new org.json.JSONArray().put( + new JSONObject().put("name", "userId").put("type", "String").put("idAttribute", true))) + .put("dependencies", new org.json.JSONArray()); + + JSONObject stateField = new JSONObject() + .put("name", "userId") + .put("type", "String") + .put("idAttribute", true); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)); + + JSONObject slice = new JSONObject() + .put("id", "create-wallet") + .put("title", "Create Wallet") + .put("sliceType", "STATE_CHANGE") + .put("commands", new org.json.JSONArray()) + .put("events", new org.json.JSONArray()) + .put("external_events", new org.json.JSONArray().put(externalEvent)) + .put("readmodels", new org.json.JSONArray()) + .put("screens", new org.json.JSONArray()) + .put("processors", new org.json.JSONArray()) + .put("tables", new org.json.JSONArray()) + .put("specifications", new org.json.JSONArray()); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Wallet", aggregateConfig)) + .put("slices", new org.json.JSONArray().put(slice)); + + schema.validate(json); + } + + @Test + public void testExternalEventsFieldOptionalInSlice() { + JSONObject stateField = new JSONObject() + .put("name", "userId") + .put("type", "String"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)); + + JSONObject slice = new JSONObject() + .put("id", "test") + .put("title", "Test") + .put("sliceType", "STATE_CHANGE") + .put("commands", new org.json.JSONArray()) + .put("events", new org.json.JSONArray()) + .put("readmodels", new org.json.JSONArray()) + .put("screens", new org.json.JSONArray()) + .put("processors", new org.json.JSONArray()) + .put("tables", new org.json.JSONArray()) + .put("specifications", new org.json.JSONArray()); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) + .put("slices", new org.json.JSONArray().put(slice)); + + schema.validate(json); + } + + @Test + public void testExternalEventsInvalidElementRejected() { + JSONObject invalidElement = new JSONObject() + .put("id", "test-ext") + .put("title", "Test") + .put("type", "INVALID") + .put("fields", new org.json.JSONArray()) + .put("dependencies", new org.json.JSONArray()); + + JSONObject stateField = new JSONObject() + .put("name", "id") + .put("type", "String"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)); + + JSONObject slice = new JSONObject() + .put("id", "test") + .put("title", "Test") + .put("sliceType", "STATE_CHANGE") + .put("commands", new org.json.JSONArray()) + .put("events", new org.json.JSONArray()) + .put("external_events", new org.json.JSONArray().put(invalidElement)) + .put("readmodels", new org.json.JSONArray()) + .put("screens", new org.json.JSONArray()) + .put("processors", new org.json.JSONArray()) + .put("tables", new org.json.JSONArray()) + .put("specifications", new org.json.JSONArray()); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) + .put("slices", new org.json.JSONArray().put(slice)); + + assertThrows(ValidationException.class, () -> schema.validate(json)); + } + + @Test + public void testInvalidElementTypeRejected() { + JSONObject element = new JSONObject() + .put("id", "test-cmd") + .put("title", "Test") + .put("type", "INVALID") + .put("fields", new org.json.JSONArray()) + .put("dependencies", new org.json.JSONArray()); + + JSONObject slice = new JSONObject() + .put("id", "test") + .put("title", "Test") + .put("sliceType", "STATE_CHANGE") + .put("commands", new org.json.JSONArray().put(element)) + .put("events", new org.json.JSONArray()) + .put("readmodels", new org.json.JSONArray()) + .put("screens", new org.json.JSONArray()) + .put("processors", new org.json.JSONArray()) + .put("tables", new org.json.JSONArray()) + .put("specifications", new org.json.JSONArray()); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject()) + .put("slices", new org.json.JSONArray().put(slice)); + + assertThrows(ValidationException.class, () -> schema.validate(json)); + } + + private JSONObject loadJson(String resourceName) { + InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName); + assertNotNull(is, "Test resource not found: " + resourceName); + return new JSONObject(new JSONTokener(is)); + } +} diff --git a/main/codegen/src/test/resources/crypto-trading-account.json b/main/codegen/src/test/resources/crypto-trading-account.json new file mode 100644 index 00000000..9366c765 --- /dev/null +++ b/main/codegen/src/test/resources/crypto-trading-account.json @@ -0,0 +1,67 @@ +{ + "packageName": "org.elasticsoftware.cryptotrading.aggregates", + "aggregateConfig": { + "Account": { + "indexed": true, + "indexName": "Users", + "generateGDPRKeyOnCreate": true, + "stateVersion": 1, + "stateFields": [ + { "name": "userId", "type": "String", "idAttribute": true }, + { "name": "country", "type": "String" }, + { "name": "firstName", "type": "String", "piiData": true }, + { "name": "lastName", "type": "String", "piiData": true }, + { "name": "email", "type": "String", "piiData": true } + ] + } + }, + "slices": [ + { + "id": "create-account", + "title": "Create Account", + "sliceType": "STATE_CHANGE", + "aggregates": ["Account"], + "commands": [ + { + "id": "create-account-cmd", + "title": "CreateAccount", + "type": "COMMAND", + "aggregate": "Account", + "createsAggregate": true, + "description": "Create a new account", + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true }, + { "name": "country", "type": "String" }, + { "name": "firstName", "type": "String", "piiData": true }, + { "name": "lastName", "type": "String", "piiData": true }, + { "name": "email", "type": "String", "piiData": true } + ], + "dependencies": [] + } + ], + "events": [ + { + "id": "account-created-evt", + "title": "AccountCreated", + "type": "EVENT", + "aggregate": "Account", + "createsAggregate": true, + "description": "Account created", + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true }, + { "name": "country", "type": "String" }, + { "name": "firstName", "type": "String", "piiData": true }, + { "name": "lastName", "type": "String", "piiData": true }, + { "name": "email", "type": "String", "piiData": true } + ], + "dependencies": [] + } + ], + "readmodels": [], + "screens": [], + "processors": [], + "tables": [], + "specifications": [] + } + ] +} diff --git a/main/codegen/src/test/resources/crypto-trading-wallet.json b/main/codegen/src/test/resources/crypto-trading-wallet.json new file mode 100644 index 00000000..1daca2e1 --- /dev/null +++ b/main/codegen/src/test/resources/crypto-trading-wallet.json @@ -0,0 +1,317 @@ +{ + "packageName": "org.elasticsoftware.cryptotrading.aggregates", + "aggregateConfig": { + "Wallet": { + "indexed": true, + "indexName": "Users", + "generateGDPRKeyOnCreate": false, + "stateVersion": 1, + "stateFields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "balances", "type": "Custom" } + ] + } + }, + "slices": [ + { + "id": "create-wallet", + "title": "Create Wallet", + "sliceType": "STATE_CHANGE", + "aggregates": ["Wallet"], + "commands": [ + { + "id": "create-wallet-cmd", + "title": "CreateWallet", + "type": "COMMAND", + "aggregate": "Wallet", + "createsAggregate": true, + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" } + ], + "dependencies": [] + } + ], + "events": [ + { + "id": "wallet-created-evt", + "title": "WalletCreated", + "type": "EVENT", + "aggregate": "Wallet", + "createsAggregate": true, + "fields": [ + { "name": "id", "type": "String", "idAttribute": true } + ], + "dependencies": [] + } + ], + "readmodels": [], + "screens": [], + "processors": [], + "tables": [], + "specifications": [] + }, + { + "id": "create-balance", + "title": "Create Balance", + "sliceType": "STATE_CHANGE", + "aggregates": ["Wallet"], + "commands": [ + { + "id": "create-balance-cmd", + "title": "CreateBalance", + "type": "COMMAND", + "aggregate": "Wallet", + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" } + ], + "dependencies": [] + } + ], + "events": [ + { + "id": "balance-created-evt", + "title": "BalanceCreated", + "type": "EVENT", + "aggregate": "Wallet", + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" } + ], + "dependencies": [] + }, + { + "id": "balance-already-exists-err", + "title": "BalanceAlreadyExistsError", + "type": "EVENT", + "aggregate": "Wallet", + "tags": ["error"], + "fields": [ + { "name": "walletId", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" } + ], + "dependencies": [] + } + ], + "readmodels": [], + "screens": [], + "processors": [], + "tables": [], + "specifications": [] + }, + { + "id": "credit-wallet", + "title": "Credit Wallet", + "sliceType": "STATE_CHANGE", + "aggregates": ["Wallet"], + "commands": [ + { + "id": "credit-wallet-cmd", + "title": "CreditWallet", + "type": "COMMAND", + "aggregate": "Wallet", + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "amount", "type": "Decimal" } + ], + "dependencies": [] + } + ], + "events": [ + { + "id": "wallet-credited-evt", + "title": "WalletCredited", + "type": "EVENT", + "aggregate": "Wallet", + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "amount", "type": "Decimal" }, + { "name": "balance", "type": "Decimal" } + ], + "dependencies": [] + }, + { + "id": "invalid-crypto-currency-err", + "title": "InvalidCryptoCurrencyError", + "type": "EVENT", + "aggregate": "Wallet", + "tags": ["error"], + "fields": [ + { "name": "walletId", "type": "String", "idAttribute": true }, + { "name": "cryptoCurrency", "type": "String" }, + { "name": "referenceId", "type": "String", "optional": true } + ], + "dependencies": [] + }, + { + "id": "invalid-amount-err", + "title": "InvalidAmountError", + "type": "EVENT", + "aggregate": "Wallet", + "tags": ["error"], + "fields": [ + { "name": "walletId", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" } + ], + "dependencies": [] + } + ], + "readmodels": [], + "screens": [], + "processors": [], + "tables": [], + "specifications": [] + }, + { + "id": "debit-wallet", + "title": "Debit Wallet", + "sliceType": "STATE_CHANGE", + "aggregates": ["Wallet"], + "commands": [ + { + "id": "debit-wallet-cmd", + "title": "DebitWallet", + "type": "COMMAND", + "aggregate": "Wallet", + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "amount", "type": "Decimal" } + ], + "dependencies": [] + } + ], + "events": [ + { + "id": "wallet-debited-evt", + "title": "WalletDebited", + "type": "EVENT", + "aggregate": "Wallet", + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "amount", "type": "Decimal" }, + { "name": "newBalance", "type": "Decimal" } + ], + "dependencies": [] + }, + { + "id": "insufficient-funds-err", + "title": "InsufficientFundsError", + "type": "EVENT", + "aggregate": "Wallet", + "tags": ["error"], + "fields": [ + { "name": "walletId", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "availableAmount", "type": "Decimal" }, + { "name": "requestedAmount", "type": "Decimal" }, + { "name": "referenceId", "type": "String", "optional": true } + ], + "dependencies": [] + } + ], + "readmodels": [], + "screens": [], + "processors": [], + "tables": [], + "specifications": [] + }, + { + "id": "reserve-amount", + "title": "Reserve Amount", + "sliceType": "STATE_CHANGE", + "aggregates": ["Wallet"], + "commands": [ + { + "id": "reserve-amount-cmd", + "title": "ReserveAmount", + "type": "COMMAND", + "aggregate": "Wallet", + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "amount", "type": "Decimal" }, + { "name": "referenceId", "type": "String" } + ], + "dependencies": [] + } + ], + "events": [ + { + "id": "amount-reserved-evt", + "title": "AmountReserved", + "type": "EVENT", + "aggregate": "Wallet", + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "amount", "type": "Decimal" }, + { "name": "referenceId", "type": "String" } + ], + "dependencies": [] + } + ], + "readmodels": [], + "screens": [], + "processors": [], + "tables": [], + "specifications": [] + }, + { + "id": "cancel-reservation", + "title": "Cancel Reservation", + "sliceType": "STATE_CHANGE", + "aggregates": ["Wallet"], + "commands": [ + { + "id": "cancel-reservation-cmd", + "title": "CancelReservation", + "type": "COMMAND", + "aggregate": "Wallet", + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "referenceId", "type": "String" } + ], + "dependencies": [] + } + ], + "events": [ + { + "id": "reservation-cancelled-evt", + "title": "ReservationCancelled", + "type": "EVENT", + "aggregate": "Wallet", + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "referenceId", "type": "String" } + ], + "dependencies": [] + }, + { + "id": "reservation-not-found-err", + "title": "ReservationNotFoundError", + "type": "EVENT", + "aggregate": "Wallet", + "tags": ["error"], + "fields": [ + { "name": "id", "type": "String", "idAttribute": true }, + { "name": "currency", "type": "String" }, + { "name": "referenceId", "type": "String" } + ], + "dependencies": [] + } + ], + "readmodels": [], + "screens": [], + "processors": [], + "tables": [], + "specifications": [] + } + ] +} diff --git a/main/pom.xml b/main/pom.xml index 26ec5e6a..b2a5f714 100644 --- a/main/pom.xml +++ b/main/pom.xml @@ -398,6 +398,7 @@ shared query-support eventcatalog + codegen