From 87d3111ea0256627f9403933dfa605f7b20e8861 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:21:19 +0000 Subject: [PATCH 01/16] Add codegen module with event-modeling JSON to Akces aggregate code generator Creates a new main/codegen module that: - Parses event-modeling JSON schema (extended with Akces config) - Generates Command records, Event records, State records, and Aggregate classes - Includes crypto-trading Account and Wallet aggregate JSON definitions - All 16 tests pass verifying structural equivalence with actual crypto-trading code Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/b53bdf75-8ad8-4f53-8221-e4538be6810f --- main/codegen/pom.xml | 61 ++ .../akces/codegen/AkcesCodeGenerator.java | 828 ++++++++++++++++++ .../akces/codegen/GeneratedFile.java | 27 + .../akces/codegen/model/AggregateConfig.java | 49 ++ .../akces/codegen/model/Dependency.java | 37 + .../akces/codegen/model/Element.java | 67 ++ .../codegen/model/EventModelDefinition.java | 39 + .../akces/codegen/model/Field.java | 73 ++ .../akces/codegen/model/Slice.java | 55 ++ .../akces/codegen/model/StateField.java | 39 + .../codegen/CryptoTradingCodeGenTest.java | 423 +++++++++ .../resources/crypto-trading-account.json | 67 ++ .../test/resources/crypto-trading-wallet.json | 317 +++++++ main/pom.xml | 1 + 14 files changed, 2083 insertions(+) create mode 100644 main/codegen/pom.xml create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/GeneratedFile.java create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/AggregateConfig.java create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Dependency.java create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Element.java create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/EventModelDefinition.java create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java create mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java create mode 100644 main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java create mode 100644 main/codegen/src/test/resources/crypto-trading-account.json create mode 100644 main/codegen/src/test/resources/crypto-trading-wallet.json diff --git a/main/codegen/pom.xml b/main/codegen/pom.xml new file mode 100644 index 00000000..2bbc0535 --- /dev/null +++ b/main/codegen/pom.xml @@ -0,0 +1,61 @@ + + + + + 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.testng + testng + test + + + + + + + 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..5cb62183 --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -0,0 +1,828 @@ +/* + * 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; + + public AkcesCodeGenerator() { + 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( + definition.packageName(), + 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 + ) { + } + + // --- 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(); + + result.computeIfAbsent(aggregateName, k -> new ArrayList<>()) + .add(new SliceData(slice, commands, successEvents, errorEvents)); + } + } + + 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"; + String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1); + + 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); + if (hasRequired) { + imports.add("jakarta.validation.constraints.NotNull"); + } + if (hasOptional) { + imports.add("jakarta.annotation.Nullable"); + } + + 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 = aggregateLower + "/commands/" + 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"; + String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1); + + 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); + if (hasRequired) { + imports.add("jakarta.validation.constraints.NotNull"); + } + if (hasOptional) { + imports.add("jakarta.annotation.Nullable"); + } + + 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 = aggregateLower + "/events/" + className + ".java"; + return new GeneratedFile(relativePath, sb.toString()); + } + + // --- State record generation --- + + private GeneratedFile generateStateRecord(String aggregatePackage, String aggregateName, AggregateConfig config) { + String className = 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.AggregateState"); + imports.add("org.elasticsoftware.akces.annotations.AggregateStateInfo"); + + boolean hasIdAttribute = config.stateFields().stream().anyMatch(StateField::idAttribute); + if (hasIdAttribute) { + imports.add("org.elasticsoftware.akces.annotations.AggregateIdentifier"); + } + + boolean hasRequired = config.stateFields().stream().anyMatch(f -> !f.optional()); + boolean hasOptional = config.stateFields().stream().anyMatch(StateField::optional); + boolean hasPii = config.stateFields().stream().anyMatch(StateField::piiData); + + if (hasRequired) { + imports.add("jakarta.validation.constraints.NotNull"); + } + if (hasOptional) { + imports.add("jakarta.annotation.Nullable"); + } + if (hasPii) { + imports.add("org.elasticsoftware.akces.annotations.PIIData"); + } + + collectStateFieldTypeImports(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++) { + StateField field = fields.get(i); + sb.append(" "); + appendStateFieldAnnotations(sb, field); + sb.append(mapStateFieldType(field)).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 = findStateIdFieldName(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 = aggregateLower + "/" + 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 producesEvents = sliceData.successEvents().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)); + } + + // 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 = aggregateLower + "/" + aggregateName + ".java"; + return new GeneratedFile(relativePath, sb.toString()); + } + + private String generateCommandHandler( + String aggregateName, + String stateClassName, + Element command, + boolean creates, + List producesEvents, + List errorEvents) { + + 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"); + + // Method body - generate event construction from command fields + if (producesEvents.size() == 1) { + 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(generateEventConstructorArgs(command)); + 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 --- + + private String generateEventConstructorArgs(Element command) { + return command.fields().stream() + .map(f -> "cmd." + f.name() + "()") + .collect(Collectors.joining(", ")); + } + + 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.isOptional()) { + sb.append("@Nullable "); + } else { + sb.append("@NotNull "); + } + } + + private void appendStateFieldAnnotations(StringBuilder sb, StateField field) { + if (field.idAttribute()) { + sb.append("@AggregateIdentifier "); + } + if (field.piiData()) { + sb.append("@PIIData "); + } + if (field.optional()) { + 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); + } + } + } + + private void collectStateFieldTypeImports(List fields, Set imports) { + for (StateField field : fields) { + String javaType = mapStateFieldType(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"); + } + } + } + + /** + * Maps an event-modeling field type to a Java type. + */ + static String mapFieldType(Field field) { + String baseType = mapType(field.type()); + if (field.isList()) { + return "List<" + baseType + ">"; + } + return baseType; + } + + /** + * Maps an event-modeling state field type to a Java type. + */ + static String mapStateFieldType(StateField field) { + return mapType(field.type()); + } + + private static String mapType(String type) { + if (type == null) return "Object"; + 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"; + default -> "Object"; + }; + } + + private String findIdFieldName(List fields) { + return fields.stream() + .filter(Field::isIdAttribute) + .map(Field::name) + .findFirst() + .orElse(fields.isEmpty() ? "id" : fields.getFirst().name()); + } + + private String findStateIdFieldName(List fields) { + return fields.stream() + .filter(StateField::idAttribute) + .map(StateField::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..3590d001 --- /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..4b6dbb14 --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java @@ -0,0 +1,73 @@ +/* + * 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 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, + 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 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..cbfd5a13 --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java @@ -0,0 +1,55 @@ +/* + * 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 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 aggregates aggregate names referenced by this slice + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Slice( + String id, + String title, + String sliceType, + List commands, + List events, + List aggregates +) { + public Slice { + if (commands == null) { + commands = List.of(); + } + if (events == null) { + events = List.of(); + } + if (aggregates == null) { + aggregates = List.of(); + } + } +} diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java new file mode 100644 index 00000000..e09056ae --- /dev/null +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.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; + +/** + * Represents a field in the aggregate state definition with Akces-specific metadata. + * + * @param name the field name + * @param type the field type (using event-modeling type names) + * @param idAttribute whether this field is the aggregate identifier + * @param piiData whether this field contains PII data requiring GDPR protection + * @param optional whether the field is optional (nullable) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record StateField( + String name, + String type, + boolean idAttribute, + boolean piiData, + boolean optional +) { +} 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..10f9c3da --- /dev/null +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java @@ -0,0 +1,423 @@ +/* + * 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(); + + // --- 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("account/commands/CreateAccountCommand.java"), + "Should generate CreateAccountCommand.java"); + assertTrue(fileMap.containsKey("account/events/AccountCreatedEvent.java"), + "Should generate AccountCreatedEvent.java"); + assertTrue(fileMap.containsKey("account/AccountState.java"), + "Should generate AccountState.java"); + assertTrue(fileMap.containsKey("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, "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("@NotNull String firstName"), + "Should have firstName field"); + assertTrue(content.contains("@NotNull String lastName"), + "Should have lastName field"); + assertTrue(content.contains("@NotNull String email"), + "Should have 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, "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("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, "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, "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("wallet/commands/CreateWalletCommand.java")); + assertTrue(fileMap.containsKey("wallet/commands/CreateBalanceCommand.java")); + assertTrue(fileMap.containsKey("wallet/commands/CreditWalletCommand.java")); + assertTrue(fileMap.containsKey("wallet/commands/DebitWalletCommand.java")); + assertTrue(fileMap.containsKey("wallet/commands/ReserveAmountCommand.java")); + assertTrue(fileMap.containsKey("wallet/commands/CancelReservationCommand.java")); + + // Success Events (6) + assertTrue(fileMap.containsKey("wallet/events/WalletCreatedEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/BalanceCreatedEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/WalletCreditedEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/WalletDebitedEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/AmountReservedEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/ReservationCancelledEvent.java")); + + // Error Events (5) + assertTrue(fileMap.containsKey("wallet/events/BalanceAlreadyExistsErrorEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/InvalidCryptoCurrencyErrorEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/InvalidAmountErrorEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/InsufficientFundsErrorEvent.java")); + assertTrue(fileMap.containsKey("wallet/events/ReservationNotFoundErrorEvent.java")); + + // State + Aggregate class + assertTrue(fileMap.containsKey("wallet/WalletState.java")); + assertTrue(fileMap.containsKey("wallet/Wallet.java")); + } + + @Test + public void testGeneratedWalletCreateCommand() { + EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); + List files = generator.generate(definition); + + GeneratedFile file = findFile(files, "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, "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, "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, "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, "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, "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, "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, "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")); + } + + // --- 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/resources/crypto-trading-account.json b/main/codegen/src/test/resources/crypto-trading-account.json new file mode 100644 index 00000000..d9d495b7 --- /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" }, + { "name": "lastName", "type": "String" }, + { "name": "email", "type": "String" } + ], + "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" }, + { "name": "lastName", "type": "String" }, + { "name": "email", "type": "String" } + ], + "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 b35139b3..642ac864 100644 --- a/main/pom.xml +++ b/main/pom.xml @@ -398,6 +398,7 @@ shared query-support eventcatalog + codegen From 550cf72cc39c6dbad3921ae0453f977cdfa3d43e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:24:02 +0000 Subject: [PATCH 02/16] Add file output test and register codegen in BOM Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/b53bdf75-8ad8-4f53-8221-e4538be6810f --- bom/pom.xml | 5 ++++ .../codegen/CryptoTradingCodeGenTest.java | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) 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/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java index 10f9c3da..8b9088e2 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java @@ -406,6 +406,35 @@ public void testGeneratedAmountReservedEvent() { 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) { From b4872c1b496d4e808e3644aafab6c8c38bd45c2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:26:21 +0000 Subject: [PATCH 03/16] Address code review: consolidate type mapping, validate unknown types Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/b53bdf75-8ad8-4f53-8221-e4538be6810f --- .../akces/codegen/AkcesCodeGenerator.java | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) 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 index 5cb62183..09aacb8d 100644 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -435,7 +435,7 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg StateField field = fields.get(i); sb.append(" "); appendStateFieldAnnotations(sb, field); - sb.append(mapStateFieldType(field)).append(" ").append(field.name()); + sb.append(mapType(field.type())).append(" ").append(field.name()); if (i < fields.size() - 1) { sb.append(",\n"); } else { @@ -752,7 +752,7 @@ private void collectFieldTypeImports(List fields, Set imports) { private void collectStateFieldTypeImports(List fields, Set imports) { for (StateField field : fields) { - String javaType = mapStateFieldType(field); + String javaType = mapType(field.type()); if (javaType.equals("BigDecimal")) { imports.add("java.math.BigDecimal"); } else if (javaType.equals("LocalDate")) { @@ -764,7 +764,7 @@ private void collectStateFieldTypeImports(List fields, Set i } /** - * Maps an event-modeling field type to a Java type. + * Maps an event-modeling field type to a Java type, handling list cardinality. */ static String mapFieldType(Field field) { String baseType = mapType(field.type()); @@ -775,14 +775,12 @@ static String mapFieldType(Field field) { } /** - * Maps an event-modeling state field type to a Java type. + * Maps an event-modeling type name to a Java type. */ - static String mapStateFieldType(StateField field) { - return mapType(field.type()); - } - - private static String mapType(String type) { - if (type == null) return "Object"; + 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"; @@ -792,7 +790,8 @@ private static String mapType(String type) { case "Int" -> "Integer"; case "Date" -> "LocalDate"; case "DateTime" -> "LocalDateTime"; - default -> "Object"; + case "Custom" -> "Object"; + default -> throw new IllegalArgumentException("Unknown field type: " + type); }; } From 640eac553a1ffd697687a0f769fc62ef8188b3f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:02:24 +0000 Subject: [PATCH 04/16] Add augmented JSON schema and schema validation test suite - Add akces-event-model.schema.json: extends the event-modeling spec with Akces-specific properties (packageName, aggregateConfig with stateFields, piiData, indexing, GDPR key generation) - Add SchemaValidationTest with 13 tests validating JSON files conform to the augmented schema, plus negative tests for invalid inputs - Add everit-json-schema test dependency for JSON Schema validation Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/62653830-241d-483b-b0f5-18bb05af8e7f --- main/codegen/pom.xml | 14 + .../resources/akces-event-model.schema.json | 363 ++++++++++++++++++ .../akces/codegen/SchemaValidationTest.java | 270 +++++++++++++ 3 files changed, 647 insertions(+) create mode 100644 main/codegen/src/main/resources/akces-event-model.schema.json create mode 100644 main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java diff --git a/main/codegen/pom.xml b/main/codegen/pom.xml index 2bbc0535..5129bfb9 100644 --- a/main/codegen/pom.xml +++ b/main/codegen/pom.xml @@ -38,6 +38,11 @@ tools.jackson.core jackson-databind + + com.github.erosb + everit-json-schema + test + org.testng testng @@ -46,6 +51,15 @@ + + + false + ${basedir}/src/main/resources + + **/*.json + + + false 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..a28f69cf --- /dev/null +++ b/main/codegen/src/main/resources/akces-event-model.schema.json @@ -0,0 +1,363 @@ +{ + "$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/StateField" }, + "description": "The fields of the aggregate state" + } + }, + "required": ["stateFields"], + "additionalProperties": false + }, + + "StateField": { + "type": "object", + "description": "A field in the aggregate state definition with Akces-specific metadata", + "properties": { + "name": { + "type": "string", + "description": "The field name" + }, + "type": { + "type": "string", + "enum": [ + "String", + "Boolean", + "Double", + "Decimal", + "Long", + "Custom", + "Date", + "DateTime", + "UUID", + "Int" + ], + "description": "The field type (using event-modeling type names)" + }, + "idAttribute": { + "type": "boolean", + "description": "Whether this field is the aggregate identifier" + }, + "piiData": { + "type": "boolean", + "description": "Whether this field contains PII data requiring GDPR protection" + }, + "optional": { + "type": "boolean", + "description": "Whether the field is optional (nullable)" + } + }, + "required": ["name", "type"], + "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" } + }, + "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" }, + "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/SchemaValidationTest.java b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java new file mode 100644 index 00000000..c5fdd8b0 --- /dev/null +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java @@ -0,0 +1,270 @@ +/* + * 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 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)); + } +} From 4532a422e7a7292ff02c2b445a2a1b9070baac00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:18:21 +0000 Subject: [PATCH 05/16] Add compilation test and fix event constructor arg generation - Add CompilationTest that generates Java source files from event-modeling JSON, writes them to disk, and compiles them using javax.tools.JavaCompiler to verify the generated code actually compiles - Add akces-api test dependency to provide Akces interfaces and annotations on the test compilation classpath - Fix generateCommandHandler to map event constructor args from the event's fields (not the command's fields), using typed default values for event fields not present in the command Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/53fc3b58-a104-49f1-8c00-051a5c41fb69 --- main/codegen/pom.xml | 6 + .../akces/codegen/AkcesCodeGenerator.java | 56 +++++- .../akces/codegen/CompilationTest.java | 178 ++++++++++++++++++ 3 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java diff --git a/main/codegen/pom.xml b/main/codegen/pom.xml index 5129bfb9..6d37ee1e 100644 --- a/main/codegen/pom.xml +++ b/main/codegen/pom.xml @@ -38,6 +38,12 @@ tools.jackson.core jackson-databind + + org.elasticsoftwarefoundation.akces + akces-api + ${project.version} + test + com.github.erosb everit-json-schema 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 index 09aacb8d..23834c8e 100644 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -545,7 +545,8 @@ private GeneratedFile generateAggregateClass( generatedCommandHandlers.add(cmd.title()); // Find events produced by this command (events in the same slice) - List producesEvents = sliceData.successEvents().stream() + List successEvts = sliceData.successEvents(); + List producesEvents = successEvts.stream() .map(e -> e.title() + "Event.class") .toList(); List errorEventNames = sliceData.errorEvents().stream() @@ -555,7 +556,7 @@ private GeneratedFile generateAggregateClass( boolean creates = cmd.createsAggregate() != null && cmd.createsAggregate(); sb.append("\n"); sb.append(generateCommandHandler( - aggregateName, stateClassName, cmd, creates, producesEvents, errorEventNames)); + aggregateName, stateClassName, cmd, creates, producesEvents, errorEventNames, successEvts)); } // Generate event sourcing handlers for success events @@ -583,7 +584,8 @@ private String generateCommandHandler( Element command, boolean creates, List producesEvents, - List errorEvents) { + List errorEvents, + List successEventElements) { StringBuilder sb = new StringBuilder(); String cmdClassName = command.title() + "Command"; @@ -614,12 +616,13 @@ private String generateCommandHandler( } sb.append(") {\n"); - // Method body - generate event construction from command fields - if (producesEvents.size() == 1) { + // 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(generateEventConstructorArgs(command)); + sb.append(generateEventConstructorArgsFromCommand(command, event)); sb.append("));\n"); } else { sb.append(" // TODO: Implement command handler logic\n"); @@ -683,12 +686,47 @@ private String generateEventSourcingHandler( // --- Helper methods --- - private String generateEventConstructorArgs(Element command) { - return command.fields().stream() - .map(f -> "cmd." + f.name() + "()") + /** + * 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() 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..bc34dcf9 --- /dev/null +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java @@ -0,0 +1,178 @@ +/* + * 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.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +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. + */ +public class CompilationTest { + + private final AkcesCodeGenerator generator = new AkcesCodeGenerator(); + + @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 = Files.createTempDirectory("akces-codegen-compile-test"); + try { + generator.generateToDirectory(accountDef, outputDir); + generator.generateToDirectory(walletDef, outputDir); + + List sourceFiles = collectJavaFiles(outputDir); + assertFalse(sourceFiles.isEmpty(), "Should have generated Java source files"); + + compileAndAssert(sourceFiles, outputDir); + } finally { + deleteRecursively(outputDir); + } + } + + /** + * Generates code from the definition, writes it to a temp directory, + * and compiles it using the Java Compiler API. + */ + private void assertCompiles(EventModelDefinition definition, String aggregateName) throws Exception { + Path outputDir = Files.createTempDirectory("akces-codegen-compile-test"); + try { + 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); + } finally { + deleteRecursively(outputDir); + } + } + + /** + * Compiles the given Java source files and asserts that compilation succeeds. + */ + 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"); + + // Create output directory for compiled classes + Path classOutputDir = outputDir.resolve("classes"); + Files.createDirectories(classOutputDir); + + List options = List.of( + "-classpath", classpath, + "-d", classOutputDir.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); + } + + private void deleteRecursively(Path dir) throws Exception { + if (Files.exists(dir)) { + try (var walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + } +} From 98cd6b695da3d621a55f972e087f707c3f92bef4 Mon Sep 17 00:00:00 2001 From: Joost van de Wijgerd Date: Thu, 26 Mar 2026 14:26:11 +0100 Subject: [PATCH 06/16] Potential fix for pull request finding 'Useless parameter' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java | 1 + 1 file changed, 1 insertion(+) 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 index 23834c8e..3b78179e 100644 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -615,6 +615,7 @@ private String generateCommandHandler( 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) { From 262e897d9b355c6a2e9ca4f35f13e7a03a00ee73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:30:41 +0000 Subject: [PATCH 07/16] Write generated sources to target/generated-test-sources instead of temp dir Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/97be6153-b154-4b46-9232-b8ed79d27306 --- .../akces/codegen/CompilationTest.java | 72 ++++++++----------- 1 file changed, 31 insertions(+), 41 deletions(-) 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 index bc34dcf9..660de875 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java @@ -21,12 +21,10 @@ import org.testng.annotations.Test; import javax.tools.*; -import java.io.File; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import static org.testng.Assert.*; @@ -35,9 +33,14 @@ * 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/akces-codegen} + * 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", "akces-codegen"); + private final AkcesCodeGenerator generator = new AkcesCodeGenerator(); @Test @@ -59,45 +62,42 @@ public void testBothAggregatesCompileTogether() throws Exception { EventModelDefinition accountDef = loadDefinition("crypto-trading-account.json"); EventModelDefinition walletDef = loadDefinition("crypto-trading-wallet.json"); - Path outputDir = Files.createTempDirectory("akces-codegen-compile-test"); - try { - generator.generateToDirectory(accountDef, outputDir); - generator.generateToDirectory(walletDef, outputDir); + Path outputDir = GENERATED_SOURCES_DIR; + Files.createDirectories(outputDir); - List sourceFiles = collectJavaFiles(outputDir); - assertFalse(sourceFiles.isEmpty(), "Should have generated Java source files"); + generator.generateToDirectory(accountDef, outputDir); + generator.generateToDirectory(walletDef, outputDir); - compileAndAssert(sourceFiles, outputDir); - } finally { - deleteRecursively(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 a temp directory, + * Generates code from the definition, writes it to + * {@code target/generated-test-sources/akces-codegen}, * and compiles it using the Java Compiler API. */ private void assertCompiles(EventModelDefinition definition, String aggregateName) throws Exception { - Path outputDir = Files.createTempDirectory("akces-codegen-compile-test"); - try { - 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()); - } + 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"); + List sourceFiles = collectJavaFiles(outputDir); + assertFalse(sourceFiles.isEmpty(), "Should have .java files on disk"); - compileAndAssert(sourceFiles, outputDir); - } finally { - deleteRecursively(outputDir); - } + compileAndAssert(sourceFiles, outputDir); } /** @@ -165,14 +165,4 @@ private EventModelDefinition loadDefinition(String resourceName) { assertNotNull(is, "Test resource not found: " + resourceName); return generator.parse(is); } - - private void deleteRecursively(Path dir) throws Exception { - if (Files.exists(dir)) { - try (var walk = Files.walk(dir)) { - walk.sorted(Comparator.reverseOrder()) - .map(Path::toFile) - .forEach(File::delete); - } - } - } } From a7ef2aa5ca55850994b45ed7d9cf5279e6dee70d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:43:31 +0000 Subject: [PATCH 08/16] Compile generated sources into target/test-classes directory Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/b30dff13-d92e-460f-9b47-4ae9135227ab --- .../elasticsoftware/akces/codegen/CompilationTest.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 660de875..8ce92717 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java @@ -40,6 +40,7 @@ public class CompilationTest { private static final Path GENERATED_SOURCES_DIR = Path.of("target", "generated-test-sources", "akces-codegen"); + private static final Path TEST_CLASSES_DIR = Path.of("target", "test-classes"); private final AkcesCodeGenerator generator = new AkcesCodeGenerator(); @@ -102,6 +103,7 @@ private void assertCompiles(EventModelDefinition definition, String aggregateNam /** * 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(); @@ -112,13 +114,12 @@ private void compileAndAssert(List sourceFiles, Path outputDir) throws Exc // Set the classpath to include the test classpath (which has akces-api and its transitive deps) String classpath = System.getProperty("java.class.path"); - // Create output directory for compiled classes - Path classOutputDir = outputDir.resolve("classes"); - Files.createDirectories(classOutputDir); + // Compile into the Maven test-classes directory + Files.createDirectories(TEST_CLASSES_DIR); List options = List.of( "-classpath", classpath, - "-d", classOutputDir.toString() + "-d", TEST_CLASSES_DIR.toString() ); Iterable compilationUnits = fileManager.getJavaFileObjectsFromPaths(sourceFiles); From 099aa09793d5b9c839b393701787cc6dfddf100e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:53:57 +0000 Subject: [PATCH 09/16] Add basePackage constructor parameter to AkcesCodeGenerator Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/77cf2f48-5f1a-4852-b219-7249a9025ad3 --- .../akces/codegen/AkcesCodeGenerator.java | 19 +++-- .../akces/codegen/CompilationTest.java | 2 +- .../codegen/CryptoTradingCodeGenTest.java | 72 +++++++++---------- 3 files changed, 50 insertions(+), 43 deletions(-) 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 index 3b78179e..a44adf4d 100644 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -67,8 +67,15 @@ public final class AkcesCodeGenerator { """; private final JsonMapper jsonMapper; + private final String basePackage; - public AkcesCodeGenerator() { + /** + * 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(); @@ -110,7 +117,7 @@ public List generate(EventModelDefinition definition) { : null; files.addAll(generateAggregate( - definition.packageName(), + basePackage, aggregateName, config, slices)); @@ -303,7 +310,7 @@ private GeneratedFile generateCommandRecord(String aggregatePackage, Element com sb.append(" }\n"); sb.append("}\n"); - String relativePath = aggregateLower + "/commands/" + className + ".java"; + String relativePath = commandsPackage.replace('.', '/') + "/" + className + ".java"; return new GeneratedFile(relativePath, sb.toString()); } @@ -378,7 +385,7 @@ private GeneratedFile generateEventRecord(String aggregatePackage, Element event sb.append(" }\n"); sb.append("}\n"); - String relativePath = aggregateLower + "/events/" + className + ".java"; + String relativePath = eventsPackage.replace('.', '/') + "/" + className + ".java"; return new GeneratedFile(relativePath, sb.toString()); } @@ -453,7 +460,7 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg sb.append(" }\n"); sb.append("}\n"); - String relativePath = aggregateLower + "/" + className + ".java"; + String relativePath = aggregatePackage.replace('.', '/') + "/" + className + ".java"; return new GeneratedFile(relativePath, sb.toString()); } @@ -574,7 +581,7 @@ private GeneratedFile generateAggregateClass( sb.append("}\n"); - String relativePath = aggregateLower + "/" + aggregateName + ".java"; + String relativePath = aggregatePackage.replace('.', '/') + "/" + aggregateName + ".java"; return new GeneratedFile(relativePath, sb.toString()); } 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 index 8ce92717..9840bc66 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java @@ -42,7 +42,7 @@ public class CompilationTest { private static final Path GENERATED_SOURCES_DIR = Path.of("target", "generated-test-sources", "akces-codegen"); private static final Path TEST_CLASSES_DIR = Path.of("target", "test-classes"); - private final AkcesCodeGenerator generator = new AkcesCodeGenerator(); + private final AkcesCodeGenerator generator = new AkcesCodeGenerator("com.example.aggregates"); @Test public void testAccountGeneratedCodeCompiles() throws Exception { 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 index 8b9088e2..6a3b42d2 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java @@ -34,7 +34,7 @@ */ public class CryptoTradingCodeGenTest { - private final AkcesCodeGenerator generator = new AkcesCodeGenerator(); + private final AkcesCodeGenerator generator = new AkcesCodeGenerator("com.example.aggregates"); // --- Account Aggregate Tests --- @@ -61,13 +61,13 @@ public void testGenerateAccountFiles() { .collect(Collectors.toMap(GeneratedFile::relativePath, f -> f)); // Verify expected files are generated - assertTrue(fileMap.containsKey("account/commands/CreateAccountCommand.java"), + assertTrue(fileMap.containsKey("com/example/aggregates/account/commands/CreateAccountCommand.java"), "Should generate CreateAccountCommand.java"); - assertTrue(fileMap.containsKey("account/events/AccountCreatedEvent.java"), + assertTrue(fileMap.containsKey("com/example/aggregates/account/events/AccountCreatedEvent.java"), "Should generate AccountCreatedEvent.java"); - assertTrue(fileMap.containsKey("account/AccountState.java"), + assertTrue(fileMap.containsKey("com/example/aggregates/account/AccountState.java"), "Should generate AccountState.java"); - assertTrue(fileMap.containsKey("account/Account.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"); @@ -78,7 +78,7 @@ public void testGeneratedAccountCommand() { EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); List files = generator.generate(definition); - GeneratedFile commandFile = findFile(files, "account/commands/CreateAccountCommand.java"); + GeneratedFile commandFile = findFile(files, "com/example/aggregates/account/commands/CreateAccountCommand.java"); assertNotNull(commandFile); String content = commandFile.content(); @@ -111,7 +111,7 @@ public void testGeneratedAccountEvent() { EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); List files = generator.generate(definition); - GeneratedFile eventFile = findFile(files, "account/events/AccountCreatedEvent.java"); + GeneratedFile eventFile = findFile(files, "com/example/aggregates/account/events/AccountCreatedEvent.java"); assertNotNull(eventFile); String content = eventFile.content(); @@ -136,7 +136,7 @@ public void testGeneratedAccountState() { EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); List files = generator.generate(definition); - GeneratedFile stateFile = findFile(files, "account/AccountState.java"); + GeneratedFile stateFile = findFile(files, "com/example/aggregates/account/AccountState.java"); assertNotNull(stateFile); String content = stateFile.content(); @@ -167,7 +167,7 @@ public void testGeneratedAccountAggregate() { EventModelDefinition definition = loadDefinition("crypto-trading-account.json"); List files = generator.generate(definition); - GeneratedFile aggregateFile = findFile(files, "account/Account.java"); + GeneratedFile aggregateFile = findFile(files, "com/example/aggregates/account/Account.java"); assertNotNull(aggregateFile); String content = aggregateFile.content(); @@ -223,31 +223,31 @@ public void testGenerateWalletFiles() { .collect(Collectors.toMap(GeneratedFile::relativePath, f -> f)); // Commands (6) - assertTrue(fileMap.containsKey("wallet/commands/CreateWalletCommand.java")); - assertTrue(fileMap.containsKey("wallet/commands/CreateBalanceCommand.java")); - assertTrue(fileMap.containsKey("wallet/commands/CreditWalletCommand.java")); - assertTrue(fileMap.containsKey("wallet/commands/DebitWalletCommand.java")); - assertTrue(fileMap.containsKey("wallet/commands/ReserveAmountCommand.java")); - assertTrue(fileMap.containsKey("wallet/commands/CancelReservationCommand.java")); + 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("wallet/events/WalletCreatedEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/BalanceCreatedEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/WalletCreditedEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/WalletDebitedEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/AmountReservedEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/ReservationCancelledEvent.java")); + 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("wallet/events/BalanceAlreadyExistsErrorEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/InvalidCryptoCurrencyErrorEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/InvalidAmountErrorEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/InsufficientFundsErrorEvent.java")); - assertTrue(fileMap.containsKey("wallet/events/ReservationNotFoundErrorEvent.java")); + 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("wallet/WalletState.java")); - assertTrue(fileMap.containsKey("wallet/Wallet.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/WalletState.java")); + assertTrue(fileMap.containsKey("com/example/aggregates/wallet/Wallet.java")); } @Test @@ -255,7 +255,7 @@ public void testGeneratedWalletCreateCommand() { EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); List files = generator.generate(definition); - GeneratedFile file = findFile(files, "wallet/commands/CreateWalletCommand.java"); + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/CreateWalletCommand.java"); assertNotNull(file); String content = file.content(); @@ -272,7 +272,7 @@ public void testGeneratedWalletCreditCommand() { EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); List files = generator.generate(definition); - GeneratedFile file = findFile(files, "wallet/commands/CreditWalletCommand.java"); + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/CreditWalletCommand.java"); assertNotNull(file); String content = file.content(); @@ -286,7 +286,7 @@ public void testGeneratedWalletCreditedEvent() { EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); List files = generator.generate(definition); - GeneratedFile file = findFile(files, "wallet/events/WalletCreditedEvent.java"); + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/WalletCreditedEvent.java"); assertNotNull(file); String content = file.content(); @@ -302,7 +302,7 @@ public void testGeneratedInsufficientFundsErrorEvent() { EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); List files = generator.generate(definition); - GeneratedFile file = findFile(files, "wallet/events/InsufficientFundsErrorEvent.java"); + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/InsufficientFundsErrorEvent.java"); assertNotNull(file); String content = file.content(); @@ -321,7 +321,7 @@ public void testGeneratedWalletAggregate() { EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); List files = generator.generate(definition); - GeneratedFile file = findFile(files, "wallet/Wallet.java"); + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/Wallet.java"); assertNotNull(file); String content = file.content(); @@ -359,7 +359,7 @@ public void testGeneratedWalletState() { EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); List files = generator.generate(definition); - GeneratedFile file = findFile(files, "wallet/WalletState.java"); + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/WalletState.java"); assertNotNull(file); String content = file.content(); @@ -375,7 +375,7 @@ public void testGeneratedReserveAmountCommand() { EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); List files = generator.generate(definition); - GeneratedFile file = findFile(files, "wallet/commands/ReserveAmountCommand.java"); + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/ReserveAmountCommand.java"); assertNotNull(file); String content = file.content(); @@ -394,7 +394,7 @@ public void testGeneratedAmountReservedEvent() { EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json"); List files = generator.generate(definition); - GeneratedFile file = findFile(files, "wallet/events/AmountReservedEvent.java"); + GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/AmountReservedEvent.java"); assertNotNull(file); String content = file.content(); From b3cfe209beb930e3c5911581eda6d5fcf5c8d8ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:04:09 +0000 Subject: [PATCH 10/16] Write generated sources to root of generated-test-sources directory Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/a17a7419-3fd2-4bc7-b872-46b0d2da5fa1 --- .../org/elasticsoftware/akces/codegen/CompilationTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 9840bc66..30fd89cf 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java @@ -34,12 +34,12 @@ * 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/akces-codegen} + * 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", "akces-codegen"); + 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"); @@ -77,7 +77,7 @@ public void testBothAggregatesCompileTogether() throws Exception { /** * Generates code from the definition, writes it to - * {@code target/generated-test-sources/akces-codegen}, + * {@code target/generated-test-sources}, * and compiles it using the Java Compiler API. */ private void assertCompiles(EventModelDefinition definition, String aggregateName) throws Exception { From 75a7756853fa5b6748da14f7cbb794026e121189 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:38:36 +0000 Subject: [PATCH 11/16] Add @PIIData annotation support on generated Command and Event field definitions Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/4721cb7c-f433-4df8-9027-40e317c87a39 Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> --- .../akces/codegen/AkcesCodeGenerator.java | 11 +++++++++++ .../akces/codegen/model/Field.java | 9 +++++++++ .../resources/akces-event-model.schema.json | 4 ++++ .../codegen/CryptoTradingCodeGenTest.java | 18 ++++++++++++------ .../test/resources/crypto-trading-account.json | 12 ++++++------ 5 files changed, 42 insertions(+), 12 deletions(-) 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 index a44adf4d..553e182a 100644 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -266,12 +266,16 @@ private GeneratedFile generateCommandRecord(String aggregatePackage, Element com 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); @@ -341,12 +345,16 @@ private GeneratedFile generateEventRecord(String aggregatePackage, Element event 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); @@ -756,6 +764,9 @@ 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 { 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 index 4b6dbb14..978388bc 100644 --- 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 @@ -28,6 +28,7 @@ * @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) @@ -39,6 +40,7 @@ public record Field( String type, Boolean optional, Boolean idAttribute, + Boolean piiData, String cardinality, List subfields, Boolean technicalAttribute, @@ -57,6 +59,13 @@ 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). */ diff --git a/main/codegen/src/main/resources/akces-event-model.schema.json b/main/codegen/src/main/resources/akces-event-model.schema.json index a28f69cf..e3065523 100644 --- a/main/codegen/src/main/resources/akces-event-model.schema.json +++ b/main/codegen/src/main/resources/akces-event-model.schema.json @@ -347,6 +347,10 @@ }, "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" }, 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 index 6a3b42d2..28041baa 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java @@ -96,12 +96,12 @@ public void testGeneratedAccountCommand() { "Should have userId field"); assertTrue(content.contains("@NotNull String country"), "Should have country field"); - assertTrue(content.contains("@NotNull String firstName"), - "Should have firstName field"); - assertTrue(content.contains("@NotNull String lastName"), - "Should have lastName field"); - assertTrue(content.contains("@NotNull String email"), - "Should have email 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"); } @@ -127,6 +127,12 @@ public void testGeneratedAccountEvent() { "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"); } diff --git a/main/codegen/src/test/resources/crypto-trading-account.json b/main/codegen/src/test/resources/crypto-trading-account.json index d9d495b7..9366c765 100644 --- a/main/codegen/src/test/resources/crypto-trading-account.json +++ b/main/codegen/src/test/resources/crypto-trading-account.json @@ -32,9 +32,9 @@ "fields": [ { "name": "userId", "type": "String", "idAttribute": true }, { "name": "country", "type": "String" }, - { "name": "firstName", "type": "String" }, - { "name": "lastName", "type": "String" }, - { "name": "email", "type": "String" } + { "name": "firstName", "type": "String", "piiData": true }, + { "name": "lastName", "type": "String", "piiData": true }, + { "name": "email", "type": "String", "piiData": true } ], "dependencies": [] } @@ -50,9 +50,9 @@ "fields": [ { "name": "userId", "type": "String", "idAttribute": true }, { "name": "country", "type": "String" }, - { "name": "firstName", "type": "String" }, - { "name": "lastName", "type": "String" }, - { "name": "email", "type": "String" } + { "name": "firstName", "type": "String", "piiData": true }, + { "name": "lastName", "type": "String", "piiData": true }, + { "name": "email", "type": "String", "piiData": true } ], "dependencies": [] } From c040b01265816742e24ce5d0300dab19a6843718 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:05:14 +0000 Subject: [PATCH 12/16] Add SCHEMA_DIFFERENCES.md documenting Akces extensions to the event-modeling spec schema Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/7e9ace42-a4f7-4a15-b965-222abbf6d814 Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> --- main/codegen/SCHEMA_DIFFERENCES.md | 142 +++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 main/codegen/SCHEMA_DIFFERENCES.md diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md new file mode 100644 index 00000000..2c2434b8 --- /dev/null +++ b/main/codegen/SCHEMA_DIFFERENCES.md @@ -0,0 +1,142 @@ +# 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` | `StateField[]` | **Yes** | The fields of the aggregate state record | + +**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 } + ] + } +} +``` + +--- + +## New Definition: `StateField` + +Defines a field in the aggregate state record with Akces-specific metadata for PII marking and +identifier designation. + +| Property | Type | Required | Description | +|---|---|---|---| +| `name` | `string` | **Yes** | The field name | +| `type` | `string` (enum) | **Yes** | The field type — same enum as `Field.type`: `String`, `Boolean`, `Double`, `Decimal`, `Long`, `Custom`, `Date`, `DateTime`, `UUID`, `Int` | +| `idAttribute` | `boolean` | No | Whether this field is the aggregate identifier (maps to `@AggregateIdentifier`) | +| `piiData` | `boolean` | No | Whether this field contains PII data (maps to `@PIIData` annotation) | +| `optional` | `boolean` | No | Whether the field is optional/nullable (maps to `@Nullable` instead of `@NotNull`) | + +--- + +## 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: + +- **`Slice`** — Represents a vertical slice with commands, events, read models, screens, processors, tables, specifications, and actors +- **`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 | +| `StateField` | New `$defs` entry | State record field with PII and identifier markers | +| `piiData` | Added to `Field` | Marks command/event fields as containing PII data | From 13b6c3918c92f044f3967b5aebd3ae4dddf35d69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:25:38 +0000 Subject: [PATCH 13/16] Add ExternalEventHandler to schema for cross-aggregate @EventHandler support Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/e48b9560-faa9-428c-ba26-c745c77a9035 Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> --- main/codegen/SCHEMA_DIFFERENCES.md | 65 +++++++- .../resources/akces-event-model.schema.json | 41 +++++ .../akces/codegen/SchemaValidationTest.java | 147 ++++++++++++++++++ 3 files changed, 251 insertions(+), 2 deletions(-) diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md index 2c2434b8..57e6ca46 100644 --- a/main/codegen/SCHEMA_DIFFERENCES.md +++ b/main/codegen/SCHEMA_DIFFERENCES.md @@ -60,6 +60,7 @@ annotations. | `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` | `StateField[]` | **Yes** | The fields of the aggregate state record | +| `externalEventHandlers` | `ExternalEventHandler[]` | No | Handlers for domain events from other aggregates (maps to `@EventHandler` annotation) | **Example:** @@ -75,6 +76,23 @@ annotations. { "name": "firstName", "type": "String", "piiData": true }, { "name": "email", "type": "String", "piiData": true } ] + }, + "Wallet": { + "stateFields": [ + { "name": "userId", "type": "String", "idAttribute": true } + ], + "externalEventHandlers": [ + { + "eventName": "AccountCreated", + "sourceAggregate": "Account", + "create": true, + "produces": ["WalletCreated"], + "errors": [], + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true } + ] + } + ] } } ``` @@ -96,6 +114,48 @@ identifier designation. --- +## New Definition: `ExternalEventHandler` + +Defines a handler for an external domain event from another aggregate. In the Akces Framework, +aggregates (especially Process Managers) can react to events produced by other aggregates using the +`@EventHandler` annotation. This definition captures that cross-aggregate event handling pattern. + +| Property | Type | Required | Description | +|---|---|---|---| +| `eventName` | `string` | **Yes** | Name of the external domain event (e.g., `"AccountCreated"`) | +| `sourceAggregate` | `string` | **Yes** | Name of the aggregate that produces this event (e.g., `"Account"`) | +| `create` | `boolean` | No | Whether handling this event creates a new aggregate instance (maps to `@EventHandler(create = true)`) | +| `produces` | `string[]` | **Yes** | Names of domain events produced by this handler (maps to `@EventHandler(produces = {...})`) | +| `errors` | `string[]` | No | Names of error events produced by this handler (maps to `@EventHandler(errors = {...})`) | +| `fields` | `Field[]` | No | Fields of the external event | + +**Example** — Wallet aggregate reacting to Account's `AccountCreatedEvent`: + +```json +{ + "eventName": "AccountCreated", + "sourceAggregate": "Account", + "create": true, + "produces": ["WalletCreated"], + "errors": [], + "fields": [ + { "name": "userId", "type": "String", "idAttribute": true } + ] +} +``` + +This maps to the following generated code: + +```java +@EventHandler(create = true, produces = WalletCreatedEvent.class, errors = {}) +public Stream create(AccountCreatedEvent event, WalletState isNull) { + // TODO: implement business logic + return Stream.of(new WalletCreatedEvent(event.userId())); +} +``` + +--- + ## Modified Definition: `Field` The `Field` definition (used in Element fields for commands, events, read models, etc.) has one @@ -136,7 +196,8 @@ The following definitions are **identical** to the original event-modeling spec | 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 | +| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields, external event handlers) | +| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition, external event handlers | +| `ExternalEventHandler` | New `$defs` entry | Handler for external domain events from other aggregates (`@EventHandler`) | | `StateField` | New `$defs` entry | State record field with PII and identifier markers | | `piiData` | Added to `Field` | Marks command/event fields as containing PII data | diff --git a/main/codegen/src/main/resources/akces-event-model.schema.json b/main/codegen/src/main/resources/akces-event-model.schema.json index e3065523..5c8512bc 100644 --- a/main/codegen/src/main/resources/akces-event-model.schema.json +++ b/main/codegen/src/main/resources/akces-event-model.schema.json @@ -47,12 +47,53 @@ "type": "array", "items": { "$ref": "#/$defs/StateField" }, "description": "The fields of the aggregate state" + }, + "externalEventHandlers": { + "type": "array", + "items": { "$ref": "#/$defs/ExternalEventHandler" }, + "description": "Handlers for domain events from other aggregates (maps to @EventHandler annotation)" } }, "required": ["stateFields"], "additionalProperties": false }, + "ExternalEventHandler": { + "type": "object", + "description": "Defines a handler for an external domain event from another aggregate, mapping to the @EventHandler annotation", + "properties": { + "eventName": { + "type": "string", + "description": "Name of the external domain event (e.g., 'AccountCreated')" + }, + "sourceAggregate": { + "type": "string", + "description": "Name of the aggregate that produces this event" + }, + "create": { + "type": "boolean", + "description": "Whether handling this event creates a new aggregate instance" + }, + "produces": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of domain events produced by this handler" + }, + "errors": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of error events produced by this handler" + }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/Field" }, + "description": "Fields of the external event" + } + }, + "required": ["eventName", "sourceAggregate", "produces"], + "additionalProperties": false + }, + "StateField": { "type": "object", "description": "A field in the aggregate state definition with Akces-specific metadata", 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 index c5fdd8b0..b380be3a 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java @@ -233,6 +233,153 @@ public void testAggregateConfigWithAllProperties() { schema.validate(json); } + @Test + public void testExternalEventHandlerAccepted() { + JSONObject handler = new JSONObject() + .put("eventName", "AccountCreated") + .put("sourceAggregate", "Account") + .put("create", true) + .put("produces", new org.json.JSONArray().put("WalletCreated")) + .put("errors", new org.json.JSONArray()) + .put("fields", new org.json.JSONArray().put( + new JSONObject().put("name", "userId").put("type", "String").put("idAttribute", true))); + + 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)) + .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Wallet", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + schema.validate(json); + } + + @Test + public void testExternalEventHandlerMinimalAccepted() { + JSONObject handler = new JSONObject() + .put("eventName", "AmountReserved") + .put("sourceAggregate", "Wallet") + .put("produces", new org.json.JSONArray().put("BuyOrderPlaced").put("SellOrderPlaced")); + + JSONObject stateField = new JSONObject() + .put("name", "userId") + .put("type", "String"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)) + .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("OrderProcessManager", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + schema.validate(json); + } + + @Test + public void testExternalEventHandlerMissingRequiredEventName() { + JSONObject handler = new JSONObject() + .put("sourceAggregate", "Account") + .put("produces", new org.json.JSONArray().put("WalletCreated")); + + JSONObject stateField = new JSONObject() + .put("name", "id") + .put("type", "String"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)) + .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); + assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("eventName")), + "Should report missing eventName: " + ex.getAllMessages()); + } + + @Test + public void testExternalEventHandlerMissingRequiredSourceAggregate() { + JSONObject handler = new JSONObject() + .put("eventName", "AccountCreated") + .put("produces", new org.json.JSONArray().put("WalletCreated")); + + JSONObject stateField = new JSONObject() + .put("name", "id") + .put("type", "String"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)) + .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); + assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("sourceAggregate")), + "Should report missing sourceAggregate: " + ex.getAllMessages()); + } + + @Test + public void testExternalEventHandlerMissingRequiredProduces() { + JSONObject handler = new JSONObject() + .put("eventName", "AccountCreated") + .put("sourceAggregate", "Account"); + + JSONObject stateField = new JSONObject() + .put("name", "id") + .put("type", "String"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)) + .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + + JSONObject json = new JSONObject() + .put("packageName", "com.example") + .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) + .put("slices", new org.json.JSONArray()); + + ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); + assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("produces")), + "Should report missing produces: " + ex.getAllMessages()); + } + + @Test + public void testExternalEventHandlerAdditionalPropertiesRejected() { + JSONObject handler = new JSONObject() + .put("eventName", "AccountCreated") + .put("sourceAggregate", "Account") + .put("produces", new org.json.JSONArray().put("WalletCreated")) + .put("unknownProp", "value"); + + JSONObject stateField = new JSONObject() + .put("name", "id") + .put("type", "String"); + + JSONObject aggregateConfig = new JSONObject() + .put("stateFields", new org.json.JSONArray().put(stateField)) + .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + + 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 testInvalidElementTypeRejected() { JSONObject element = new JSONObject() From 5cfacd9de63b89fd5b8fcc615a5ffddfd3ba885e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:42:45 +0000 Subject: [PATCH 14/16] Remove StateField, reuse Field for aggregate state in codegen schema Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/4dacb82e-8135-4bb7-a9b9-79a4f73e59c0 Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> --- main/codegen/SCHEMA_DIFFERENCES.md | 18 +----- .../akces/codegen/AkcesCodeGenerator.java | 55 ++++--------------- .../akces/codegen/model/AggregateConfig.java | 2 +- .../akces/codegen/model/StateField.java | 39 ------------- .../resources/akces-event-model.schema.json | 43 +-------------- 5 files changed, 13 insertions(+), 144 deletions(-) delete mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md index 57e6ca46..31a7fdf2 100644 --- a/main/codegen/SCHEMA_DIFFERENCES.md +++ b/main/codegen/SCHEMA_DIFFERENCES.md @@ -59,7 +59,7 @@ annotations. | `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` | `StateField[]` | **Yes** | The fields of the aggregate state record | +| `stateFields` | `Field[]` | **Yes** | The fields of the aggregate state record (reuses the standard `Field` definition) | | `externalEventHandlers` | `ExternalEventHandler[]` | No | Handlers for domain events from other aggregates (maps to `@EventHandler` annotation) | **Example:** @@ -99,21 +99,6 @@ annotations. --- -## New Definition: `StateField` - -Defines a field in the aggregate state record with Akces-specific metadata for PII marking and -identifier designation. - -| Property | Type | Required | Description | -|---|---|---|---| -| `name` | `string` | **Yes** | The field name | -| `type` | `string` (enum) | **Yes** | The field type — same enum as `Field.type`: `String`, `Boolean`, `Double`, `Decimal`, `Long`, `Custom`, `Date`, `DateTime`, `UUID`, `Int` | -| `idAttribute` | `boolean` | No | Whether this field is the aggregate identifier (maps to `@AggregateIdentifier`) | -| `piiData` | `boolean` | No | Whether this field contains PII data (maps to `@PIIData` annotation) | -| `optional` | `boolean` | No | Whether the field is optional/nullable (maps to `@Nullable` instead of `@NotNull`) | - ---- - ## New Definition: `ExternalEventHandler` Defines a handler for an external domain event from another aggregate. In the Akces Framework, @@ -199,5 +184,4 @@ The following definitions are **identical** to the original event-modeling spec | `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields, external event handlers) | | `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition, external event handlers | | `ExternalEventHandler` | New `$defs` entry | Handler for external domain events from other aggregates (`@EventHandler`) | -| `StateField` | New `$defs` entry | State record field with PII and identifier markers | | `piiData` | Added to `Field` | Marks command/event fields as containing PII data | 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 index 553e182a..bea7bd3e 100644 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -412,14 +412,14 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg imports.add("org.elasticsoftware.akces.aggregate.AggregateState"); imports.add("org.elasticsoftware.akces.annotations.AggregateStateInfo"); - boolean hasIdAttribute = config.stateFields().stream().anyMatch(StateField::idAttribute); + 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.optional()); - boolean hasOptional = config.stateFields().stream().anyMatch(StateField::optional); - boolean hasPii = config.stateFields().stream().anyMatch(StateField::piiData); + 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"); @@ -431,7 +431,7 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg imports.add("org.elasticsoftware.akces.annotations.PIIData"); } - collectStateFieldTypeImports(config.stateFields(), imports); + collectFieldTypeImports(config.stateFields(), imports); for (String imp : imports) { sb.append("import ").append(imp).append(";\n"); @@ -445,11 +445,11 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg // Record declaration sb.append("public record ").append(className).append("(\n"); - List fields = config.stateFields(); + List fields = config.stateFields(); for (int i = 0; i < fields.size(); i++) { - StateField field = fields.get(i); + Field field = fields.get(i); sb.append(" "); - appendStateFieldAnnotations(sb, field); + appendFieldAnnotations(sb, field); sb.append(mapType(field.type())).append(" ").append(field.name()); if (i < fields.size() - 1) { sb.append(",\n"); @@ -461,7 +461,7 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg sb.append(") implements AggregateState {\n"); // getAggregateId method - String idFieldName = findStateIdFieldName(fields); + String idFieldName = findIdFieldName(fields); sb.append(" @Override\n"); sb.append(" public String getAggregateId() {\n"); sb.append(" return ").append(idFieldName).append("();\n"); @@ -744,7 +744,7 @@ private String getDefaultValue(Field field) { } private String generateStateConstructorArgsFromEvent(AggregateConfig config, Element event) { - List stateFields = config.stateFields(); + List stateFields = config.stateFields(); Set eventFieldNames = event.fields().stream() .map(Field::name) .collect(Collectors.toSet()); @@ -774,20 +774,6 @@ private void appendFieldAnnotations(StringBuilder sb, Field field) { } } - private void appendStateFieldAnnotations(StringBuilder sb, StateField field) { - if (field.idAttribute()) { - sb.append("@AggregateIdentifier "); - } - if (field.piiData()) { - sb.append("@PIIData "); - } - if (field.optional()) { - sb.append("@Nullable "); - } else { - sb.append("@NotNull "); - } - } - private void collectFieldTypeImports(List fields, Set imports) { for (Field field : fields) { String javaType = mapFieldType(field); @@ -807,19 +793,6 @@ private void collectFieldTypeImports(List fields, Set imports) { } } - private void collectStateFieldTypeImports(List fields, Set imports) { - for (StateField field : fields) { - String javaType = mapType(field.type()); - 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"); - } - } - } - /** * Maps an event-modeling field type to a Java type, handling list cardinality. */ @@ -860,14 +833,6 @@ private String findIdFieldName(List fields) { .orElse(fields.isEmpty() ? "id" : fields.getFirst().name()); } - private String findStateIdFieldName(List fields) { - return fields.stream() - .filter(StateField::idAttribute) - .map(StateField::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" 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 index 3590d001..2e7e3971 100644 --- 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 @@ -36,7 +36,7 @@ public record AggregateConfig( String indexName, boolean generateGDPRKeyOnCreate, int stateVersion, - List stateFields + List stateFields ) { public AggregateConfig { if (stateVersion <= 0) { diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java deleted file mode 100644 index e09056ae..00000000 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 field in the aggregate state definition with Akces-specific metadata. - * - * @param name the field name - * @param type the field type (using event-modeling type names) - * @param idAttribute whether this field is the aggregate identifier - * @param piiData whether this field contains PII data requiring GDPR protection - * @param optional whether the field is optional (nullable) - */ -@JsonIgnoreProperties(ignoreUnknown = true) -public record StateField( - String name, - String type, - boolean idAttribute, - boolean piiData, - boolean optional -) { -} diff --git a/main/codegen/src/main/resources/akces-event-model.schema.json b/main/codegen/src/main/resources/akces-event-model.schema.json index 5c8512bc..a1256442 100644 --- a/main/codegen/src/main/resources/akces-event-model.schema.json +++ b/main/codegen/src/main/resources/akces-event-model.schema.json @@ -45,7 +45,7 @@ }, "stateFields": { "type": "array", - "items": { "$ref": "#/$defs/StateField" }, + "items": { "$ref": "#/$defs/Field" }, "description": "The fields of the aggregate state" }, "externalEventHandlers": { @@ -94,47 +94,6 @@ "additionalProperties": false }, - "StateField": { - "type": "object", - "description": "A field in the aggregate state definition with Akces-specific metadata", - "properties": { - "name": { - "type": "string", - "description": "The field name" - }, - "type": { - "type": "string", - "enum": [ - "String", - "Boolean", - "Double", - "Decimal", - "Long", - "Custom", - "Date", - "DateTime", - "UUID", - "Int" - ], - "description": "The field type (using event-modeling type names)" - }, - "idAttribute": { - "type": "boolean", - "description": "Whether this field is the aggregate identifier" - }, - "piiData": { - "type": "boolean", - "description": "Whether this field contains PII data requiring GDPR protection" - }, - "optional": { - "type": "boolean", - "description": "Whether the field is optional (nullable)" - } - }, - "required": ["name", "type"], - "additionalProperties": false - }, - "Slice": { "type": "object", "properties": { From ebb5a92ad0daf71a5ab8d4dedf1c155ed6a691a7 Mon Sep 17 00:00:00 2001 From: Joost van de Wijgerd Date: Mon, 30 Mar 2026 15:11:50 +0200 Subject: [PATCH 15/16] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java | 3 --- 1 file changed, 3 deletions(-) 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 index bea7bd3e..c019d287 100644 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -248,7 +248,6 @@ private List generateAggregate( private GeneratedFile generateCommandRecord(String aggregatePackage, Element command) { String className = command.title() + "Command"; String commandsPackage = aggregatePackage + ".commands"; - String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1); StringBuilder sb = new StringBuilder(); sb.append(LICENSE_HEADER).append("\n"); @@ -323,7 +322,6 @@ private GeneratedFile generateCommandRecord(String aggregatePackage, Element com private GeneratedFile generateEventRecord(String aggregatePackage, Element event, boolean isError) { String className = event.title() + "Event"; String eventsPackage = aggregatePackage + ".events"; - String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1); StringBuilder sb = new StringBuilder(); sb.append(LICENSE_HEADER).append("\n"); @@ -401,7 +399,6 @@ private GeneratedFile generateEventRecord(String aggregatePackage, Element event private GeneratedFile generateStateRecord(String aggregatePackage, String aggregateName, AggregateConfig config) { String className = aggregateName + "State"; - String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1); StringBuilder sb = new StringBuilder(); sb.append(LICENSE_HEADER).append("\n"); From 32325e1682808cdead9aa9b8b3b22bea98558cb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:43:05 +0000 Subject: [PATCH 16/16] Move external events from AggregateConfig to Slice as external_events Element array Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/cfed3c79-d934-4609-9b5d-5e8a48c5bdc1 Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com> --- main/codegen/SCHEMA_DIFFERENCES.md | 99 ++++++----- .../akces/codegen/AkcesCodeGenerator.java | 8 +- .../akces/codegen/model/Slice.java | 18 +- .../resources/akces-event-model.schema.json | 45 +---- .../akces/codegen/SchemaValidationTest.java | 159 +++++++----------- 5 files changed, 129 insertions(+), 200 deletions(-) diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md index 31a7fdf2..49cb847c 100644 --- a/main/codegen/SCHEMA_DIFFERENCES.md +++ b/main/codegen/SCHEMA_DIFFERENCES.md @@ -60,7 +60,6 @@ annotations. | `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) | -| `externalEventHandlers` | `ExternalEventHandler[]` | No | Handlers for domain events from other aggregates (maps to `@EventHandler` annotation) | **Example:** @@ -76,66 +75,65 @@ annotations. { "name": "firstName", "type": "String", "piiData": true }, { "name": "email", "type": "String", "piiData": true } ] - }, - "Wallet": { - "stateFields": [ - { "name": "userId", "type": "String", "idAttribute": true } - ], - "externalEventHandlers": [ - { - "eventName": "AccountCreated", - "sourceAggregate": "Account", - "create": true, - "produces": ["WalletCreated"], - "errors": [], - "fields": [ - { "name": "userId", "type": "String", "idAttribute": true } - ] - } - ] } } ``` --- -## New Definition: `ExternalEventHandler` +## Modified Definition: `Slice` -Defines a handler for an external domain event from another aggregate. In the Akces Framework, -aggregates (especially Process Managers) can react to events produced by other aggregates using the -`@EventHandler` annotation. This definition captures that cross-aggregate event handling pattern. +The `Slice` definition has one addition compared to the original event-modeling spec schema: | Property | Type | Required | Description | |---|---|---|---| -| `eventName` | `string` | **Yes** | Name of the external domain event (e.g., `"AccountCreated"`) | -| `sourceAggregate` | `string` | **Yes** | Name of the aggregate that produces this event (e.g., `"Account"`) | -| `create` | `boolean` | No | Whether handling this event creates a new aggregate instance (maps to `@EventHandler(create = true)`) | -| `produces` | `string[]` | **Yes** | Names of domain events produced by this handler (maps to `@EventHandler(produces = {...})`) | -| `errors` | `string[]` | No | Names of error events produced by this handler (maps to `@EventHandler(errors = {...})`) | -| `fields` | `Field[]` | No | Fields of the external event | +| **`external_events`** *(new)* | `Element[]` | No | External events from other aggregates consumed in this slice | -**Example** — Wallet aggregate reacting to Account's `AccountCreatedEvent`: +All other `Slice` properties are unchanged from the original schema. -```json -{ - "eventName": "AccountCreated", - "sourceAggregate": "Account", - "create": true, - "produces": ["WalletCreated"], - "errors": [], - "fields": [ - { "name": "userId", "type": "String", "idAttribute": true } - ] -} -``` +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. -This maps to the following generated code: +**Example** — Wallet aggregate consuming Account's `AccountCreatedEvent`: -```java -@EventHandler(create = true, produces = WalletCreatedEvent.class, errors = {}) -public Stream create(AccountCreatedEvent event, WalletState isNull) { - // TODO: implement business logic - return Stream.of(new WalletCreatedEvent(event.userId())); +```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": [] } ``` @@ -164,7 +162,6 @@ marked across commands, events, and state records. The following definitions are **identical** to the original event-modeling spec schema: -- **`Slice`** — Represents a vertical slice with commands, events, read models, screens, processors, tables, specifications, and actors - **`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 @@ -181,7 +178,7 @@ The following definitions are **identical** to the original event-modeling spec | Addition | Location | Purpose | |---|---|---| | `packageName` | Root property | Java package name declared in the definition | -| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields, external event handlers) | -| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition, external event handlers | -| `ExternalEventHandler` | New `$defs` entry | Handler for external domain events from other aggregates (`@EventHandler`) | +| `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/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java index c019d287..bda2de1b 100644 --- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java +++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java @@ -150,7 +150,8 @@ private record SliceData( Slice slice, List commands, List successEvents, - List errorEvents + List errorEvents, + List externalEvents ) { } @@ -180,8 +181,11 @@ private Map> groupByAggregate(EventModelDefinition defin 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)); + .add(new SliceData(slice, commands, successEvents, errorEvents, externalEvents)); } } 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 index cbfd5a13..2a735771 100644 --- 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 @@ -18,6 +18,7 @@ package org.elasticsoftware.akces.codegen.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; @@ -25,12 +26,13 @@ * 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 aggregates aggregate names referenced by this slice + * @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( @@ -39,6 +41,7 @@ public record Slice( String sliceType, List commands, List events, + @JsonProperty("external_events") List externalEvents, List aggregates ) { public Slice { @@ -48,6 +51,9 @@ public record Slice( 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 index a1256442..4d7de2c0 100644 --- a/main/codegen/src/main/resources/akces-event-model.schema.json +++ b/main/codegen/src/main/resources/akces-event-model.schema.json @@ -47,53 +47,12 @@ "type": "array", "items": { "$ref": "#/$defs/Field" }, "description": "The fields of the aggregate state" - }, - "externalEventHandlers": { - "type": "array", - "items": { "$ref": "#/$defs/ExternalEventHandler" }, - "description": "Handlers for domain events from other aggregates (maps to @EventHandler annotation)" } }, "required": ["stateFields"], "additionalProperties": false }, - "ExternalEventHandler": { - "type": "object", - "description": "Defines a handler for an external domain event from another aggregate, mapping to the @EventHandler annotation", - "properties": { - "eventName": { - "type": "string", - "description": "Name of the external domain event (e.g., 'AccountCreated')" - }, - "sourceAggregate": { - "type": "string", - "description": "Name of the aggregate that produces this event" - }, - "create": { - "type": "boolean", - "description": "Whether handling this event creates a new aggregate instance" - }, - "produces": { - "type": "array", - "items": { "type": "string" }, - "description": "Names of domain events produced by this handler" - }, - "errors": { - "type": "array", - "items": { "type": "string" }, - "description": "Names of error events produced by this handler" - }, - "fields": { - "type": "array", - "items": { "$ref": "#/$defs/Field" }, - "description": "Fields of the external event" - } - }, - "required": ["eventName", "sourceAggregate", "produces"], - "additionalProperties": false - }, - "Slice": { "type": "object", "properties": { @@ -117,6 +76,10 @@ "type": "array", "items": { "$ref": "#/$defs/Element" } }, + "external_events": { + "type": "array", + "items": { "$ref": "#/$defs/Element" } + }, "readmodels": { "type": "array", "items": { "$ref": "#/$defs/Element" } 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 index b380be3a..f0851c4f 100644 --- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java +++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java @@ -234,15 +234,15 @@ public void testAggregateConfigWithAllProperties() { } @Test - public void testExternalEventHandlerAccepted() { - JSONObject handler = new JSONObject() - .put("eventName", "AccountCreated") - .put("sourceAggregate", "Account") - .put("create", true) - .put("produces", new org.json.JSONArray().put("WalletCreated")) - .put("errors", new org.json.JSONArray()) + 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))); + new JSONObject().put("name", "userId").put("type", "String").put("idAttribute", true))) + .put("dependencies", new org.json.JSONArray()); JSONObject stateField = new JSONObject() .put("name", "userId") @@ -250,132 +250,91 @@ public void testExternalEventHandlerAccepted() { .put("idAttribute", true); JSONObject aggregateConfig = new JSONObject() - .put("stateFields", new org.json.JSONArray().put(stateField)) - .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + .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("slices", new org.json.JSONArray().put(slice)); schema.validate(json); } @Test - public void testExternalEventHandlerMinimalAccepted() { - JSONObject handler = new JSONObject() - .put("eventName", "AmountReserved") - .put("sourceAggregate", "Wallet") - .put("produces", new org.json.JSONArray().put("BuyOrderPlaced").put("SellOrderPlaced")); - + 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)) - .put("externalEventHandlers", new org.json.JSONArray().put(handler)); - - JSONObject json = new JSONObject() - .put("packageName", "com.example") - .put("aggregateConfig", new JSONObject().put("OrderProcessManager", aggregateConfig)) - .put("slices", new org.json.JSONArray()); - - schema.validate(json); - } - - @Test - public void testExternalEventHandlerMissingRequiredEventName() { - JSONObject handler = new JSONObject() - .put("sourceAggregate", "Account") - .put("produces", new org.json.JSONArray().put("WalletCreated")); - - JSONObject stateField = new JSONObject() - .put("name", "id") - .put("type", "String"); - - JSONObject aggregateConfig = new JSONObject() - .put("stateFields", new org.json.JSONArray().put(stateField)) - .put("externalEventHandlers", new org.json.JSONArray().put(handler)); - - JSONObject json = new JSONObject() - .put("packageName", "com.example") - .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) - .put("slices", new org.json.JSONArray()); - - ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); - assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("eventName")), - "Should report missing eventName: " + ex.getAllMessages()); - } - - @Test - public void testExternalEventHandlerMissingRequiredSourceAggregate() { - JSONObject handler = new JSONObject() - .put("eventName", "AccountCreated") - .put("produces", new org.json.JSONArray().put("WalletCreated")); - - JSONObject stateField = new JSONObject() - .put("name", "id") - .put("type", "String"); + .put("stateFields", new org.json.JSONArray().put(stateField)); - JSONObject aggregateConfig = new JSONObject() - .put("stateFields", new org.json.JSONArray().put(stateField)) - .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + 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("slices", new org.json.JSONArray().put(slice)); - ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); - assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("sourceAggregate")), - "Should report missing sourceAggregate: " + ex.getAllMessages()); + schema.validate(json); } @Test - public void testExternalEventHandlerMissingRequiredProduces() { - JSONObject handler = new JSONObject() - .put("eventName", "AccountCreated") - .put("sourceAggregate", "Account"); + 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)) - .put("externalEventHandlers", new org.json.JSONArray().put(handler)); - - JSONObject json = new JSONObject() - .put("packageName", "com.example") - .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig)) - .put("slices", new org.json.JSONArray()); - - ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json)); - assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("produces")), - "Should report missing produces: " + ex.getAllMessages()); - } - - @Test - public void testExternalEventHandlerAdditionalPropertiesRejected() { - JSONObject handler = new JSONObject() - .put("eventName", "AccountCreated") - .put("sourceAggregate", "Account") - .put("produces", new org.json.JSONArray().put("WalletCreated")) - .put("unknownProp", "value"); - - JSONObject stateField = new JSONObject() - .put("name", "id") - .put("type", "String"); + .put("stateFields", new org.json.JSONArray().put(stateField)); - JSONObject aggregateConfig = new JSONObject() - .put("stateFields", new org.json.JSONArray().put(stateField)) - .put("externalEventHandlers", new org.json.JSONArray().put(handler)); + 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("slices", new org.json.JSONArray().put(slice)); assertThrows(ValidationException.class, () -> schema.validate(json)); }