diff --git a/README.md b/README.md index dc4cef92..675376c6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ BpmnValidator .assertNoViolations() ``` -11 built-in rules cover missing implementations, undefined timers, empty processes, naming violations, and variable collisions. Add custom rules by implementing `SingleModelValidationRule` (per-process) or `CrossModelValidationRule` (across all loaded models). +12 built-in rules cover missing implementations, undefined timers, empty processes, naming violations, leftover root elements, and variable collisions. Add custom rules by implementing `SingleModelValidationRule` (per-process) or `CrossModelValidationRule` (across all loaded models). ### Surface — Process Structure in Code _(beta)_ @@ -51,11 +51,15 @@ Generates a structured JSON alongside the API. Your process is readable by AI ag ```json { - "processId": "newsletterSubscription", - "flowNodes": [ - { "id": "StartEvent_SubmitRegistrationForm", "displayName": "Submit newsletter form", "elementType": "START_EVENT" }, - { "id": "Activity_SendConfirmationMail", "displayName": "Send confirmation mail", "elementType": "SERVICE_TASK" } - ] + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "newsletterSubscription", + "flowNodes": [ + { "id": "StartEvent_SubmitRegistrationForm", "type": "startEvent", "name": "Submit newsletter form" }, + { "id": "Activity_SendConfirmationMail", "type": "serviceTask", "name": "Send confirmation mail" } + ] + } } ``` @@ -64,6 +68,7 @@ BPMN files are XML — technically readable, but full of visual layout data, nam - **Smaller** — no diagram coordinates, waypoints, or SVG-style metadata - **Focused** — only the elements and relationships that matter for logic and implementation - **Structured** — flow nodes, sequence flows, messages, and errors in predictable, typed fields +- **Standard** — element names, containment and references follow OMG BPMN 2.0, validated against a published JSON Schema The result is a compact, deterministic representation that AI agents can reason about accurately — with no hallucinated element IDs, because the JSON is derived directly from the BPMN model by rule. @@ -82,7 +87,7 @@ Works with Claude Code out of the box. ```kotlin plugins { - id("io.miragon.bpmn-to-code-gradle") version "3.0.0" + id("io.miragon.bpmn-to-code-gradle") version "6.0.0" } tasks.named("generateBpmnModelApi", GenerateBpmnModelsTask::class) { @@ -101,7 +106,7 @@ tasks.named("generateBpmnModelApi", GenerateBpmnModelsTask::class) { io.miragon bpmn-to-code-maven - 3.0.0 + 6.0.0 generate-bpmn-api @@ -122,7 +127,7 @@ tasks.named("generateBpmnModelApi", GenerateBpmnModelsTask::class) { ```kotlin dependencies { - testImplementation("io.miragon:bpmn-to-code-testing:3.0.0") + testImplementation("io.miragon:bpmn-to-code-testing:6.0.0") } ``` diff --git a/bpmn-to-code-architecture-tests/build.gradle.kts b/bpmn-to-code-architecture-tests/build.gradle.kts index 1ebc8054..fd980db5 100644 --- a/bpmn-to-code-architecture-tests/build.gradle.kts +++ b/bpmn-to-code-architecture-tests/build.gradle.kts @@ -20,4 +20,14 @@ dependencies { tasks.named("test") { useJUnitPlatform() + + // Konsist reads the other modules' sources from disk, which Gradle cannot see. Without declaring + // them the task stays UP-TO-DATE after any change outside this module, so a violation only surfaces + // on a clean CI checkout. + inputs.files( + fileTree(rootDir) { + include("bpmn-to-code-*/src/**/*.kt") + exclude("bpmn-to-code-architecture-tests/**") + }, + ).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("projectSources") } diff --git a/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/CodingGuidelinesTest.kt b/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/CodingGuidelinesTest.kt index 88b6f4b9..543ce392 100644 --- a/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/CodingGuidelinesTest.kt +++ b/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/CodingGuidelinesTest.kt @@ -13,11 +13,36 @@ class CodingGuidelinesTest { @Test fun `each source file declares at most one top-level type`() { - Konsist - .scopeFromProject() - .files + productionFiles() .assertTrue(testName = "files should declare at most one top-level type to follow SRP") { file -> file.classesAndInterfacesAndObjects(includeNested = false, includeLocal = false).size <= 1 } } + + /** + * A file that declares a type declares *only* that type: helpers belong inside it (or in their own + * file), not beside it. Without this, a class file slowly accumulates free functions that no reader + * expects to find there — which is how `ProcessModel.kt` grew a second, unrelated half. + * + * Files holding only top-level functions — a named collection of helpers with no type of its own — + * stay allowed. + */ + @Test + fun `a file declaring a type declares nothing else at top level`() { + productionFiles() + .filter { it.classesAndInterfacesAndObjects(includeNested = false, includeLocal = false).isNotEmpty() } + .assertTrue(testName = "a type's file should not also declare top-level functions or properties") { file -> + val functions = file.functions(includeNested = false, includeLocal = false) + val properties = file.properties(includeNested = false) + functions.isEmpty() && properties.isEmpty() + } + } + + /** + * Project sources only — never the gitignored `bin/` output an IDE may leave behind. + */ + private fun productionFiles() = Konsist + .scopeFromProject() + .files + .filter { it.path.contains("/src/") } } diff --git a/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/ExternalModuleImportTest.kt b/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/ExternalModuleImportTest.kt index 85e56d5a..11369789 100644 --- a/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/ExternalModuleImportTest.kt +++ b/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/ExternalModuleImportTest.kt @@ -33,7 +33,7 @@ class ExternalModuleImportTest { Konsist .scopeFromProject() .files - .filter { file -> file.path.contains(modulePath) } + .filter { file -> file.path.contains("/src/") && file.path.contains(modulePath) } .assertTrue { file -> file.imports.none { import -> forbiddenImportPrefixes.any { import.name.startsWith(it) } diff --git a/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/HexagonalArchitectureTest.kt b/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/HexagonalArchitectureTest.kt index e8e90708..faebc515 100644 --- a/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/HexagonalArchitectureTest.kt +++ b/bpmn-to-code-architecture-tests/src/test/kotlin/io/miragon/bpmn/architecture/HexagonalArchitectureTest.kt @@ -187,7 +187,9 @@ class HexagonalArchitectureTest { private companion object { - /** Restricts a whole-project scan to `bpmn-to-code-core`'s production sources. */ + /** + * Restricts a whole-project scan to `bpmn-to-code-core`'s production sources. + */ const val CORE_MAIN_PATH = "/bpmn-to-code-core/src/main/" /** diff --git a/bpmn-to-code-core/build.gradle.kts b/bpmn-to-code-core/build.gradle.kts index edf40b34..77284670 100644 --- a/bpmn-to-code-core/build.gradle.kts +++ b/bpmn-to-code-core/build.gradle.kts @@ -20,24 +20,30 @@ dependencies { api(libs.kotlinLogging) testImplementation(libs.bundles.testing) testImplementation(kotlin("compiler-embeddable")) + testImplementation(libs.jsonSchemaValidator) testRuntimeOnly(libs.junitPlatformLauncher) } sourceSets { test { resources.srcDir(rootProject.file("shared")) + // The published JSON schema, so ProcessJsonSchemaTest validates against it offline + resources.srcDir(rootProject.file("docs/public/schema")) } } tasks.named("test") { useJUnitPlatform() + // Lets `./gradlew test -Dgolden.update=true` rewrite the end-to-end JSON snapshots. + systemProperty("golden.update", System.getProperty("golden.update") ?: "false") } private val coverageExclusions = listOf( "**/domain/shared/**", "**/domain/validation/model/**", - "**/adapter/outbound/engine/constants/**", - "**/adapter/outbound/engine/extractor/*ImplementationKind*", + // Constant holders: their `const val`s are inlined at the call site, so the object itself + // never executes. Matched by name rather than by package so a move cannot silently re-include them. + "**/adapter/outbound/engine/**/*Constants*", "**/adapter/outbound/json/model/**", "**/application/port/**", "**/*\$DefaultImpls*", diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/inbound/ExtractProcessModelsPlugin.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/inbound/ExtractProcessModelsPlugin.kt new file mode 100644 index 00000000..0e9670ab --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/inbound/ExtractProcessModelsPlugin.kt @@ -0,0 +1,16 @@ +package io.miragon.bpmn.adapter.inbound + +import io.miragon.bpmn.application.port.inbound.ExtractProcessModelsUseCase +import io.miragon.bpmn.application.service.ExtractProcessModelsService +import io.miragon.bpmn.domain.BpmnResource +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.shared.ProcessEngine + +class ExtractProcessModelsPlugin( + private val useCase: ExtractProcessModelsUseCase = ExtractProcessModelsService(), +) { + + fun execute(resources: List, engine: ProcessEngine): List = useCase.extractProcessModels( + ExtractProcessModelsUseCase.Command(resources = resources, engine = engine), + ) +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/inbound/ValidateBpmnFilesystemPlugin.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/inbound/ValidateBpmnFilesystemPlugin.kt index 305c9eb5..cf31ab80 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/inbound/ValidateBpmnFilesystemPlugin.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/inbound/ValidateBpmnFilesystemPlugin.kt @@ -3,8 +3,8 @@ package io.miragon.bpmn.adapter.inbound import io.miragon.bpmn.application.port.inbound.ValidateBpmnFromFilesystemUseCase import io.miragon.bpmn.application.service.ValidateBpmnService import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.validation.model.ValidationConfig import io.miragon.bpmn.domain.validation.ValidationResult +import io.miragon.bpmn.domain.validation.model.ValidationConfig class ValidateBpmnFilesystemPlugin( private val useCase: ValidateBpmnFromFilesystemUseCase = ValidateBpmnService(), diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/ApiObjectSelection.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/ApiObjectSelection.kt new file mode 100644 index 00000000..6f8c0c9e --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/ApiObjectSelection.kt @@ -0,0 +1,42 @@ +package io.miragon.bpmn.adapter.outbound.codegen + +import io.miragon.bpmn.domain.BpmnModelApi + +/** + * Decides which sections a generated Process API contains. + * + * Today the only question is whether a section would have anything to say about the model — a process + * without messages gets no `Messages` object rather than an empty one. That is a mapping from the + * codegen vocabulary onto the domain, so it lives here, once, rather than in each language's builder or + * on [ApiObjectType] itself. + * + * Letting the caller choose the sections is a second, independent question. It is not implemented, but + * this is where it goes: [selectFrom] gains the requested set and nothing else has to move. + */ +internal object ApiObjectSelection { + + /** + * Whether [type] has anything to contribute for [modelApi]. + */ + fun includes(type: ApiObjectType, modelApi: BpmnModelApi): Boolean { + return type.hasContentIn(modelApi) + } + + private fun ApiObjectType.hasContentIn(modelApi: BpmnModelApi): Boolean { + val model = modelApi.model + return when (this) { + ApiObjectType.PROCESS_ID, ApiObjectType.PROCESS_ENGINE, ApiObjectType.ELEMENTS -> true + ApiObjectType.FLOWS, ApiObjectType.RELATIONS -> !model.isMerged && model.graph.allSequenceFlows.isNotEmpty() + ApiObjectType.VARIANTS -> model.isMerged + ApiObjectType.CALL_ACTIVITIES -> model.callActivities.isNotEmpty() + ApiObjectType.MESSAGES -> model.definitions.messages.isNotEmpty() + ApiObjectType.SERVICE_TASKS -> model.serviceTasks.any { it.getRawName().isNotEmpty() } + ApiObjectType.TIMERS -> model.timers.isNotEmpty() + ApiObjectType.ERRORS -> model.definitions.errors.isNotEmpty() + ApiObjectType.ESCALATIONS -> model.definitions.escalations.isNotEmpty() + ApiObjectType.COMPENSATIONS -> model.compensations.isNotEmpty() + ApiObjectType.SIGNALS -> model.definitions.signals.isNotEmpty() + ApiObjectType.VARIABLES -> model.variables.isNotEmpty() + } + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/ApiObjectType.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/ApiObjectType.kt new file mode 100644 index 00000000..206f8871 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/ApiObjectType.kt @@ -0,0 +1,27 @@ +package io.miragon.bpmn.adapter.outbound.codegen + +/** + * The sections a generated Process API can contain. + * + * A catalogue of names, nothing else. Which of them a given run actually emits is decided by + * [ApiObjectSelection] — that is a question about a model, and one day about what the caller asked for, + * neither of which is a property of the name. + */ +internal enum class ApiObjectType { + + PROCESS_ID, + PROCESS_ENGINE, + ELEMENTS, + FLOWS, + RELATIONS, + VARIANTS, + CALL_ACTIVITIES, + MESSAGES, + SERVICE_TASKS, + TIMERS, + ERRORS, + ESCALATIONS, + COMPENSATIONS, + SIGNALS, + VARIABLES, +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/CodeGenerationAdapter.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/CodeGenerationAdapter.kt index 71209e67..ef7b4614 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/CodeGenerationAdapter.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/CodeGenerationAdapter.kt @@ -7,7 +7,7 @@ import io.miragon.bpmn.domain.BpmnModelApi import io.miragon.bpmn.domain.GeneratedApiFile import io.miragon.bpmn.domain.shared.OutputLanguage -class CodeGenerationAdapter( +internal class CodeGenerationAdapter( private val processApiBuilders: Map> = Companion.processApiBuilders, ) : GenerateApiCodePort { diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/ApiConstants.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/ApiConstants.kt new file mode 100644 index 00000000..6e7dd1ad --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/ApiConstants.kt @@ -0,0 +1,14 @@ +package io.miragon.bpmn.adapter.outbound.codegen.builder + +import io.miragon.bpmn.domain.shared.VariableMapping + +/** + * Registry entries reduced to the constants the Process API can actually declare. + * + * The domain keeps every `bpmn:Definitions` root element, because flow nodes reference them by id and two + * of them may legitimately share a name. The generated API has no such id — a name yields exactly one + * constant — so the collapsing happens here, at the point where names become identifiers. + */ +internal fun > List.asApiConstants(): List { + return filter { it.getRawName().isNotEmpty() }.distinctBy { it.getRawName() } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/JavaProcessApiBuilder.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/JavaProcessApiBuilder.kt index feebb9d9..3b90794f 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/JavaProcessApiBuilder.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/JavaProcessApiBuilder.kt @@ -1,5 +1,7 @@ package io.miragon.bpmn.adapter.outbound.codegen.builder +import io.miragon.bpmn.adapter.outbound.codegen.ApiObjectSelection +import io.miragon.bpmn.adapter.outbound.codegen.ApiObjectType import com.palantir.javapoet.ClassName import com.palantir.javapoet.CodeBlock import com.palantir.javapoet.FieldSpec @@ -8,15 +10,12 @@ import com.palantir.javapoet.TypeSpec import io.miragon.bpmn.adapter.outbound.codegen.CodeGenerationAdapter import io.miragon.bpmn.adapter.outbound.codegen.writer.ObjectWriter import io.miragon.bpmn.adapter.outbound.shared.ElementTypeName -import io.miragon.bpmn.domain.BpmnModel import io.miragon.bpmn.domain.BpmnModelApi import io.miragon.bpmn.domain.GeneratedApiFile -import io.miragon.bpmn.domain.MergedBpmnModel -import io.miragon.bpmn.domain.MergedBpmnModel.VariantData -import io.miragon.bpmn.domain.shared.ApiObjectType +import io.miragon.bpmn.domain.ProcessModel.Variant import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.ProcessGraph import io.miragon.bpmn.domain.shared.SequenceFlowDefinition import io.miragon.bpmn.domain.shared.VariableDefinition import io.miragon.bpmn.domain.shared.VariableMapping @@ -30,7 +29,7 @@ import javax.lang.model.element.Modifier.STATIC * Generates the type-safe API contract for a single BPMN process as a Java class file. * References shared BPMN types (BpmnTimer, BpmnError, etc.) from the `bpmn-to-code-runtime` artifact. */ -class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder() { +internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder() { companion object { private const val RUNTIME_PACKAGE = "io.miragon.bpmn.runtime" @@ -58,8 +57,9 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder writer.write(rootClassBuilder, modelApi) } + objectWriters + .filterKeys { ApiObjectSelection.includes(it, modelApi) } + .forEach { (_, writer) -> writer.addTo(rootClassBuilder, modelApi) } val fileBuilder = JavaFile.builder(modelApi.packagePath, rootClassBuilder.build()) val javaFile = fileBuilder.addFileComment(autoGenComment).build() @@ -77,10 +77,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.PROCESS_ID - override fun shouldWrite(modelApi: BpmnModelApi) = true - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val processIdClass = ClassName.get(RUNTIME_PACKAGE, "ProcessId") val fieldBuilder = FieldSpec.builder(processIdClass, "PROCESS_ID").addModifiers(PUBLIC, FINAL, STATIC) builder.addField(fieldBuilder.initializer("new \$T(\$S)", processIdClass, modelApi.model.processId).build()) @@ -89,24 +86,18 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.PROCESS_ENGINE - override fun shouldWrite(modelApi: BpmnModelApi) = true - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val bpmnEngineClass = ClassName.get(RUNTIME_PACKAGE, "BpmnEngine") val fieldBuilder = FieldSpec.builder(bpmnEngineClass, "PROCESS_ENGINE") .addModifiers(PUBLIC, FINAL, STATIC) - .initializer("\$T.\$L", bpmnEngineClass, modelApi.engine.name) + .initializer("\$T.\$L", bpmnEngineClass, modelApi.targetEngine.name) builder.addField(fieldBuilder.build()) } } private inner class ElementsWriter : ObjectWriter { - override val objectType = ApiObjectType.ELEMENTS - override fun shouldWrite(modelApi: BpmnModelApi) = true - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val elementIdClass = ClassName.get(RUNTIME_PACKAGE, "ElementId") val elementsBuilder = TypeSpec.classBuilder("Elements").addModifiers(PUBLIC, STATIC, FINAL) .addJavadoc( @@ -114,7 +105,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder + modelApi.model.allFlowNodes.sortedBy { it.getRawName() }.forEach { flowNode -> elementsBuilder.addField(createTypedAttribute(flowNode, elementIdClass)) } builder.addType(elementsBuilder.build()) @@ -123,37 +114,24 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.FLOWS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean { - return modelApi.model is BpmnModel && modelApi.model.sequenceFlows.isNotEmpty() - } - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { - val flowsClass = buildFlowsClass(modelApi.model.sequenceFlows) + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + val flowsClass = buildFlowsClass(modelApi.model.graph.allSequenceFlows) builder.addType(flowsClass) } } private inner class RelationsWriter : ObjectWriter { - override val objectType = ApiObjectType.RELATIONS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean { - return modelApi.model is BpmnModel && modelApi.model.sequenceFlows.isNotEmpty() - } - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { - val relationsClass = buildRelationsClass(modelApi.model.flowNodes) + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + val relationsClass = buildRelationsClass(modelApi.model.graph) builder.addType(relationsClass) } } private inner class VariantsWriter : ObjectWriter { - override val objectType = ApiObjectType.VARIANTS - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model is MergedBpmnModel - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { - val model = modelApi.model as? MergedBpmnModel ?: return + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + val model = modelApi.model val variantsBuilder = TypeSpec.classBuilder("Variants").addModifiers(PUBLIC, STATIC, FINAL) model.variants.forEach { variant -> val variantClass = buildVariantClass(variant) @@ -162,12 +140,12 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder + sequenceFlows.sortedBy { it.getRawName() }.forEach { flow -> val initCode = buildFlowInitializer(bpmnFlowClass, flow.id ?: "", flow.flowName, flow.sourceRef, flow.targetRef, flow.conditionExpression, flow.isDefault) val fieldBuilder = FieldSpec.builder(bpmnFlowClass, flow.getName()).addModifiers(PUBLIC, STATIC, FINAL) flowsBuilder.addField(fieldBuilder.initializer(initCode).build()) @@ -201,43 +179,45 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder): TypeSpec { + private fun buildRelationsClass(graph: ProcessGraph): TypeSpec { val bpmnRelationsClass = ClassName.get(RUNTIME_PACKAGE, "BpmnRelations") val relationsBuilder = TypeSpec.classBuilder("Relations").addModifiers(PUBLIC, STATIC, FINAL) .addJavadoc( "Per-element graph metadata (elementType / previousElements / followingElements / parentId / boundary attachments).\n" + "Intended for tooling and tests, not worker runtime code.\n" ) - flowNodes + graph.allFlowNodes .filter { it.id != null } .sortedBy { it.getRawName() } .forEach { node -> - val initCode = buildRelationsInitializer(bpmnRelationsClass, node) + val initCode = buildRelationsInitializer(bpmnRelationsClass, node, graph) val fieldBuilder = FieldSpec.builder(bpmnRelationsClass, node.getName()).addModifiers(PUBLIC, STATIC, FINAL) relationsBuilder.addField(fieldBuilder.initializer(initCode).build()) } return relationsBuilder.build() } - private fun buildRelationsInitializer(bpmnRelationsClass: ClassName, node: FlowNodeDefinition): CodeBlock { + private fun buildRelationsInitializer(bpmnRelationsClass: ClassName, node: FlowNodeDefinition, graph: ProcessGraph): CodeBlock { + val parentId = graph.parentIdOf(node.id) + val attachedToRef = (node as? FlowNodeDefinition.Event)?.attachedToRef val nameBlock = if (node.displayName != null) CodeBlock.of("\$S", node.displayName) else CodeBlock.of("null") - val parentIdBlock = if (node.parentId != null) CodeBlock.of("\$S", node.parentId) else CodeBlock.of("null") - val attachedToRefBlock = if (node.attachedToRef != null) CodeBlock.of("\$S", node.attachedToRef) else CodeBlock.of("null") + val parentIdBlock = if (parentId != null) CodeBlock.of("\$S", parentId) else CodeBlock.of("null") + val attachedToRefBlock = if (attachedToRef != null) CodeBlock.of("\$S", attachedToRef) else CodeBlock.of("null") return CodeBlock.builder() .add("new \$T(", bpmnRelationsClass) .add(nameBlock) .add(", ") - .add(javaListLiteral(node.previousElements)) + .add(javaListLiteral(graph.previousElementsOf(node))) .add(", ") - .add(javaListLiteral(node.followingElements)) + .add(javaListLiteral(graph.followingElementsOf(node))) .add(", ") .add(parentIdBlock) .add(", ") .add(attachedToRefBlock) .add(", ") - .add(javaListLiteral(node.attachedElements)) + .add(javaListLiteral(graph.attachedElementsOf(node))) .add(", ") - .add("\$S", ElementTypeName.of(node.nodeType)) + .add("\$S", ElementTypeName.of(node)) .add(")") .build() } @@ -257,10 +237,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.CALL_ACTIVITIES - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.callActivities.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val callActivitiesBuilder = TypeSpec.classBuilder("CallActivities").addModifiers(PUBLIC, STATIC, FINAL) .addJavadoc( "Call activities grouped by element. Each nested class exposes the called {@code PROCESS_ID} plus " + @@ -285,7 +262,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder): TypeSpec? { + private fun buildMappingsClass(className: String, mappings: List): TypeSpec? { val withTarget = mappings .filter { !it.target.isNullOrBlank() } .sortedBy { it.target!!.toUpperSnakeCase() } @@ -296,7 +273,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.MESSAGES - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.messages.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val messageNameClass = ClassName.get(RUNTIME_PACKAGE, "MessageName") val messagesBuilder = TypeSpec.classBuilder("Messages").addModifiers(PUBLIC, STATIC, FINAL) .addJavadoc("BPMN message names used to correlate messages to running process instances.\n") - modelApi.model.messages.forEach { message -> + modelApi.model.definitions.messages.asApiConstants().forEach { message -> messagesBuilder.addField(createTypedAttribute(message, messageNameClass)) } builder.addType(messagesBuilder.build()) @@ -331,17 +305,13 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.SERVICE_TASKS - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.serviceTasks.any { it.getRawName().isNotEmpty() } - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val tasksBuilder = TypeSpec.classBuilder("ServiceTasks").addModifiers(PUBLIC, STATIC, FINAL) .addJavadoc( "Job worker task types used in {@code @JobWorker(type = ServiceTasks.X)} annotations.\n" + "Kept as {@code public static final String} because annotation arguments must be compile-time constants.\n" ) - modelApi.model.serviceTasks - .filter { it.getRawName().isNotEmpty() } + modelApi.model.serviceTasks.asApiConstants() .forEach { task -> tasksBuilder.addField(createAttribute(task)) } builder.addType(tasksBuilder.build()) } @@ -349,13 +319,10 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.SIGNALS - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.signals.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val signalNameClass = ClassName.get(RUNTIME_PACKAGE, "SignalName") val signalsBuilder = TypeSpec.classBuilder("Signals").addModifiers(PUBLIC, STATIC, FINAL) - modelApi.model.signals.forEach { signal -> + modelApi.model.definitions.signals.asApiConstants().forEach { signal -> signalsBuilder.addField(createTypedAttribute(signal, signalNameClass)) } builder.addType(signalsBuilder.build()) @@ -364,10 +331,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.VARIABLES - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.variables.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val variableNameClass = ClassName.get(RUNTIME_PACKAGE, "VariableName") val variablesBuilder = TypeSpec.classBuilder("Variables").addModifiers(PUBLIC, STATIC, FINAL) .addJavadoc( @@ -375,7 +339,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.ERRORS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean = modelApi.model.errors.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val bpmnErrorClass = ClassName.get(RUNTIME_PACKAGE, "BpmnError") val errorsBuilder = TypeSpec.classBuilder("Errors").addModifiers(PUBLIC, STATIC, FINAL) - modelApi.model.errors.forEach { + modelApi.model.definitions.errors.asApiConstants().forEach { val (errorName, errorCode) = it.getValue() val instanceBuilder = FieldSpec.builder(bpmnErrorClass, it.getName()) val variable = instanceBuilder.addModifiers(PUBLIC, STATIC, FINAL) @@ -423,13 +384,10 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.ESCALATIONS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean = modelApi.model.escalations.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val bpmnEscalationClass = ClassName.get(RUNTIME_PACKAGE, "BpmnEscalation") val escalationsBuilder = TypeSpec.classBuilder("Escalations").addModifiers(PUBLIC, STATIC, FINAL) - modelApi.model.escalations.forEach { + modelApi.model.definitions.escalations.asApiConstants().forEach { val (escalationName, escalationCode) = it.getValue() val instanceBuilder = FieldSpec.builder(bpmnEscalationClass, it.getName()) val variable = instanceBuilder.addModifiers(PUBLIC, STATIC, FINAL) @@ -441,10 +399,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.COMPENSATIONS - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.compensations.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val elementIdClass = ClassName.get(RUNTIME_PACKAGE, "ElementId") val compensationsBuilder = TypeSpec.classBuilder("Compensations").addModifiers(PUBLIC, STATIC, FINAL) modelApi.model.compensations.forEach { compensation -> @@ -456,10 +411,7 @@ class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder { - override val objectType = ApiObjectType.TIMERS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean = modelApi.model.timers.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val bpmnTimerClass = ClassName.get(RUNTIME_PACKAGE, "BpmnTimer") val timersBuilder = TypeSpec.classBuilder("Timers").addModifiers(PUBLIC, STATIC, FINAL) modelApi.model.timers.forEach { diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/KotlinProcessApiBuilder.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/KotlinProcessApiBuilder.kt index 154bbd3d..19072ad6 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/KotlinProcessApiBuilder.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/KotlinProcessApiBuilder.kt @@ -1,5 +1,7 @@ package io.miragon.bpmn.adapter.outbound.codegen.builder +import io.miragon.bpmn.adapter.outbound.codegen.ApiObjectSelection +import io.miragon.bpmn.adapter.outbound.codegen.ApiObjectType import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock @@ -10,15 +12,12 @@ import com.squareup.kotlinpoet.TypeSpec import io.miragon.bpmn.adapter.outbound.codegen.CodeGenerationAdapter import io.miragon.bpmn.adapter.outbound.codegen.writer.ObjectWriter import io.miragon.bpmn.adapter.outbound.shared.ElementTypeName -import io.miragon.bpmn.domain.BpmnModel import io.miragon.bpmn.domain.BpmnModelApi import io.miragon.bpmn.domain.GeneratedApiFile -import io.miragon.bpmn.domain.MergedBpmnModel -import io.miragon.bpmn.domain.MergedBpmnModel.VariantData -import io.miragon.bpmn.domain.shared.ApiObjectType +import io.miragon.bpmn.domain.ProcessModel.Variant import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.ProcessGraph import io.miragon.bpmn.domain.shared.SequenceFlowDefinition import io.miragon.bpmn.domain.shared.VariableDefinition import io.miragon.bpmn.domain.shared.VariableMapping @@ -29,7 +28,7 @@ import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase * Generates the type-safe API contract for a single BPMN process as a Kotlin object file. * References shared BPMN types (BpmnTimer, BpmnError, etc.) from the `bpmn-to-code-runtime` artifact. */ -class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder() { +internal class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder() { companion object { private const val RUNTIME_PACKAGE = "io.miragon.bpmn.runtime" @@ -59,8 +58,9 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< val rootObjectBuilder = TypeSpec.objectBuilder(objectName) val fileSpecBuilder = FileSpec.builder(modelApi.packagePath, objectName).addFileComment(autoGenComment) - val relevantWriters = objectWriters.filter { it.value.shouldWrite(modelApi) } - relevantWriters.forEach { (_, writer) -> writer.write(rootObjectBuilder, modelApi) } + objectWriters + .filterKeys { ApiObjectSelection.includes(it, modelApi) } + .forEach { (_, writer) -> writer.addTo(rootObjectBuilder, modelApi) } fileSpecBuilder.addType(rootObjectBuilder.build()).addAnnotation(unusedAnnotation) val fileSpec = fileSpecBuilder.build() @@ -78,10 +78,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class ProcessIdWriter : ObjectWriter { - override val objectType = ApiObjectType.PROCESS_ID - override fun shouldWrite(modelApi: BpmnModelApi) = true - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val processIdClass = ClassName(RUNTIME_PACKAGE, "ProcessId") val idProperty = PropertySpec.builder("PROCESS_ID", processIdClass) .initializer("%T(%L)", processIdClass, stringLiteral(modelApi.model.processId)) @@ -92,13 +89,10 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private class ProcessEngineWriter : ObjectWriter { - override val objectType = ApiObjectType.PROCESS_ENGINE - override fun shouldWrite(modelApi: BpmnModelApi) = true - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val bpmnEngineClass = ClassName(RUNTIME_PACKAGE, "BpmnEngine") val engineProperty = PropertySpec.builder("PROCESS_ENGINE", bpmnEngineClass) - .initializer("%T.%L", bpmnEngineClass, modelApi.engine.name) + .initializer("%T.%L", bpmnEngineClass, modelApi.targetEngine.name) .build() builder.addProperty(engineProperty) } @@ -106,10 +100,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class ElementsWriter : ObjectWriter { - override val objectType = ApiObjectType.ELEMENTS - override fun shouldWrite(modelApi: BpmnModelApi) = true - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val elementIdClass = ClassName(RUNTIME_PACKAGE, "ElementId") val elementsBuilder = TypeSpec.objectBuilder("Elements") .addKdoc( @@ -117,7 +108,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< "Typically used in process-level tests or when searching for tasks.\n" + "Worker runtime code rarely needs these." ) - modelApi.model.flowNodes.forEach { flowNode -> + modelApi.model.allFlowNodes.sortedBy { it.getRawName() }.forEach { flowNode -> elementsBuilder.addProperty(createTypedAttribute(flowNode, elementIdClass)) } builder.addType(elementsBuilder.build()) @@ -126,37 +117,24 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class FlowsWriter : ObjectWriter { - override val objectType = ApiObjectType.FLOWS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean { - return modelApi.model is BpmnModel && modelApi.model.sequenceFlows.isNotEmpty() - } - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { - val flowsObject = buildFlowsObject(modelApi.model.sequenceFlows) + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + val flowsObject = buildFlowsObject(modelApi.model.graph.allSequenceFlows) builder.addType(flowsObject) } } private inner class RelationsWriter : ObjectWriter { - override val objectType = ApiObjectType.RELATIONS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean { - return modelApi.model is BpmnModel && modelApi.model.sequenceFlows.isNotEmpty() - } - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { - val relationsObject = buildRelationsObject(modelApi.model.flowNodes) + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + val relationsObject = buildRelationsObject(modelApi.model.graph) builder.addType(relationsObject) } } private inner class VariantsWriter : ObjectWriter { - override val objectType = ApiObjectType.VARIANTS - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model is MergedBpmnModel - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { - val model = modelApi.model as? MergedBpmnModel ?: return + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + val model = modelApi.model val variantsBuilder = TypeSpec.objectBuilder("Variants") model.variants.forEach { variant -> val variantObject = buildVariantObject(variant) @@ -165,12 +143,12 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< builder.addType(variantsBuilder.build()) } - private fun buildVariantObject(variant: VariantData): TypeSpec { + private fun buildVariantObject(variant: Variant): TypeSpec { val variantName = variant.variantName.toCamelCase() val variantBuilder = TypeSpec.objectBuilder(variantName) - if (variant.sequenceFlows.isNotEmpty()) { - variantBuilder.addType(buildFlowsObject(variant.sequenceFlows)) - variantBuilder.addType(buildRelationsObject(variant.flowNodes)) + if (variant.graph.allSequenceFlows.isNotEmpty()) { + variantBuilder.addType(buildFlowsObject(variant.graph.allSequenceFlows)) + variantBuilder.addType(buildRelationsObject(variant.graph)) } return variantBuilder.build() } @@ -184,7 +162,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< "Mainly useful for process-model tooling, tests, and AI-agent consumers reasoning about the process shape.\n" + "Worker code typically does not need these." ) - sequenceFlows.forEach { flow -> + sequenceFlows.sortedBy { it.getRawName() }.forEach { flow -> val initStr = buildFlowInitializer(flow.id ?: "", flow.flowName, flow.sourceRef, flow.targetRef, flow.conditionExpression, flow.isDefault) flowsBuilder.addProperty(PropertySpec.builder(flow.getName(), bpmnFlowClass).initializer(initStr).build()) } @@ -206,39 +184,41 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< }.build() } - private fun buildRelationsObject(flowNodes: List): TypeSpec { + private fun buildRelationsObject(graph: ProcessGraph): TypeSpec { val bpmnRelationsClass = ClassName(RUNTIME_PACKAGE, "BpmnRelations") val relationsBuilder = TypeSpec.objectBuilder("Relations") .addKdoc( "Per-element graph metadata (elementType / previousElements / followingElements / parentId / boundary attachments).\n" + "Intended for tooling and tests, not worker runtime code." ) - flowNodes + graph.allFlowNodes .filter { it.id != null } .sortedBy { it.getRawName() } .forEach { node -> - val initStr = buildRelationsInitializer(node) + val initStr = buildRelationsInitializer(node, graph) relationsBuilder.addProperty(PropertySpec.builder(node.getName(), bpmnRelationsClass).initializer(initStr).build()) } return relationsBuilder.build() } - private fun buildRelationsInitializer(node: FlowNodeDefinition): CodeBlock { + private fun buildRelationsInitializer(node: FlowNodeDefinition, graph: ProcessGraph): CodeBlock { return CodeBlock.builder().apply { add("BpmnRelations(\n") indent() if (node.displayName != null) add("name = %S,\n", node.displayName) - add("previousElements = %L,\n", listLiteral(node.previousElements)) - add("followingElements = %L,\n", listLiteral(node.followingElements)) - add("parentId = %L,\n", nullableStringLiteral(node.parentId)) - add("attachedToRef = %L,\n", nullableStringLiteral(node.attachedToRef)) - add("attachedElements = %L,\n", listLiteral(node.attachedElements)) - add("elementType = %S,\n", ElementTypeName.of(node.nodeType)) + add("previousElements = %L,\n", listLiteral(graph.previousElementsOf(node))) + add("followingElements = %L,\n", listLiteral(graph.followingElementsOf(node))) + add("parentId = %L,\n", nullableStringLiteral(graph.parentIdOf(node.id))) + add("attachedToRef = %L,\n", nullableStringLiteral(node.attachedToRef())) + add("attachedElements = %L,\n", listLiteral(graph.attachedElementsOf(node))) + add("elementType = %S,\n", ElementTypeName.of(node)) unindent() add(")") }.build() } + private fun FlowNodeDefinition.attachedToRef(): String? = (this as? FlowNodeDefinition.Event)?.attachedToRef + private fun listLiteral(items: List): String { return if (items.isEmpty()) "emptyList()" else "listOf(${items.joinToString { "\"$it\"" }})" } @@ -249,10 +229,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class CallActivitiesWriter : ObjectWriter { - override val objectType = ApiObjectType.CALL_ACTIVITIES - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.callActivities.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val callActivitiesBuilder = TypeSpec.objectBuilder("CallActivities") .addKdoc( "Call activities grouped by element. Each nested object exposes the called `PROCESS_ID` plus " + @@ -277,7 +254,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< return objectBuilder.build() } - private fun buildMappingsObject(objectName: String, mappings: List): TypeSpec? { + private fun buildMappingsObject(objectName: String, mappings: List): TypeSpec? { val withTarget = mappings .filter { !it.target.isNullOrBlank() } .sortedBy { it.target!!.toUpperSnakeCase() } @@ -288,7 +265,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< return mappingsBuilder.build() } - private fun buildMappingProperty(mapping: CallActivityMapping, mappingClass: ClassName): PropertySpec { + private fun buildMappingProperty(mapping: CallActivityDefinition.Mapping, mappingClass: ClassName): PropertySpec { val target = mapping.target!! val args = CodeBlock.builder().add("target = %L", stringLiteral(target)) if (mapping.source != null) args.add(", source = %L", stringLiteral(mapping.source)) @@ -301,14 +278,11 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class MessagesWriter : ObjectWriter { - override val objectType = ApiObjectType.MESSAGES - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.messages.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val messageNameClass = ClassName(RUNTIME_PACKAGE, "MessageName") val messagesBuilder = TypeSpec.objectBuilder("Messages") .addKdoc("BPMN message names used to correlate messages to running process instances.") - modelApi.model.messages.forEach { message -> + modelApi.model.definitions.messages.asApiConstants().forEach { message -> messagesBuilder.addProperty(createTypedAttribute(message, messageNameClass)) } builder.addType(messagesBuilder.build()) @@ -322,17 +296,13 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< */ private inner class ServiceTasksWriter : ObjectWriter { - override val objectType = ApiObjectType.SERVICE_TASKS - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.serviceTasks.any { it.getRawName().isNotEmpty() } - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val tasksBuilder = TypeSpec.objectBuilder("ServiceTasks") .addKdoc( "Job worker task types used in `@JobWorker(type = ServiceTasks.X)` annotations.\n" + "Kept as `const val String` because annotation arguments must be compile-time constants." ) - modelApi.model.serviceTasks - .filter { it.getRawName().isNotEmpty() } + modelApi.model.serviceTasks.asApiConstants() .forEach { task -> tasksBuilder.addProperty(createAttribute(task)) } builder.addType(tasksBuilder.build()) } @@ -340,13 +310,10 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class SignalsWriter : ObjectWriter { - override val objectType = ApiObjectType.SIGNALS - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.signals.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val signalNameClass = ClassName(RUNTIME_PACKAGE, "SignalName") val signalsBuilder = TypeSpec.objectBuilder("Signals") - modelApi.model.signals.forEach { signal -> + modelApi.model.definitions.signals.asApiConstants().forEach { signal -> signalsBuilder.addProperty(createTypedAttribute(signal, signalNameClass)) } builder.addType(signalsBuilder.build()) @@ -355,10 +322,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class VariablesWriter : ObjectWriter { - override val objectType = ApiObjectType.VARIABLES - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.variables.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val variableNameClass = ClassName(RUNTIME_PACKAGE, "VariableName") val variablesBuilder = TypeSpec.objectBuilder("Variables") .addKdoc( @@ -366,7 +330,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< "Direction is encoded in each variable's wrapper type: `VariableName.Input`, `VariableName.Output`, or `VariableName.InOut` when the variable is both read and written by the same element.\n" + "Consumer APIs that take a specific subtype (e.g. `fun setOutput(v: VariableName.Output)`) get compile-time direction enforcement." ) - val nodesWithVariables = modelApi.model.flowNodes + val nodesWithVariables = modelApi.model.allFlowNodes .filter { it.variables.isNotEmpty() } .sortedBy { it.getRawName() } for (node in nodesWithVariables) { @@ -395,13 +359,10 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private class ErrorsWriter : ObjectWriter { - override val objectType = ApiObjectType.ERRORS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean = modelApi.model.errors.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val bpmnErrorClass = ClassName(RUNTIME_PACKAGE, "BpmnError") val errorsBuilder = TypeSpec.objectBuilder("Errors") - modelApi.model.errors.forEach { + modelApi.model.definitions.errors.asApiConstants().forEach { val (errorName, errorCode) = it.getValue() val instanceBuilder = PropertySpec.builder(it.getName(), bpmnErrorClass) val variable = instanceBuilder.initializer("BpmnError(\"$errorName\", \"$errorCode\")") @@ -413,13 +374,10 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private class EscalationsWriter : ObjectWriter { - override val objectType = ApiObjectType.ESCALATIONS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean = modelApi.model.escalations.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val bpmnEscalationClass = ClassName(RUNTIME_PACKAGE, "BpmnEscalation") val escalationsBuilder = TypeSpec.objectBuilder("Escalations") - modelApi.model.escalations.forEach { + modelApi.model.definitions.escalations.asApiConstants().forEach { val (escalationName, escalationCode) = it.getValue() val instanceBuilder = PropertySpec.builder(it.getName(), bpmnEscalationClass) val variable = instanceBuilder.initializer("BpmnEscalation(\"$escalationName\", \"$escalationCode\")") @@ -431,10 +389,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class CompensationsWriter : ObjectWriter { - override val objectType = ApiObjectType.COMPENSATIONS - override fun shouldWrite(modelApi: BpmnModelApi) = modelApi.model.compensations.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val elementIdClass = ClassName(RUNTIME_PACKAGE, "ElementId") val compensationsBuilder = TypeSpec.objectBuilder("Compensations") modelApi.model.compensations.forEach { compensation -> @@ -446,10 +401,7 @@ class KotlinProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiBuilder< private inner class TimersWriter : ObjectWriter { - override val objectType = ApiObjectType.TIMERS - override fun shouldWrite(modelApi: BpmnModelApi): Boolean = modelApi.model.timers.isNotEmpty() - - override fun write(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { + override fun addTo(builder: TypeSpec.Builder, modelApi: BpmnModelApi) { val bpmnTimerClass = ClassName(RUNTIME_PACKAGE, "BpmnTimer") val timersBuilder = TypeSpec.objectBuilder("Timers") modelApi.model.timers.forEach { timer -> diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/writer/ObjectWriter.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/writer/ObjectWriter.kt index 8d0bbddd..4278c1ab 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/writer/ObjectWriter.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/codegen/writer/ObjectWriter.kt @@ -1,26 +1,18 @@ package io.miragon.bpmn.adapter.outbound.codegen.writer import io.miragon.bpmn.domain.BpmnModelApi -import io.miragon.bpmn.domain.shared.ApiObjectType /** - * This interface defines a contract for a code-generator - * It can be used to write a given API object (f.ex. a message or service-task) to a code builder. + * Contributes one section of the generated Process API to a language-specific builder. + * + * Nothing is written anywhere here — [addTo] hands its section to an accumulating builder, and the file + * only appears further along: the builder renders to a string, `buildApiFile` wraps that in a + * `GeneratedApiFile`, and `ProcessApiFileSaver` is what finally touches the disk. + * + * Contributing is the only thing that differs between the Kotlin and the Java builder; *whether* a + * section is contributed at all is decided once, by `ApiObjectSelection`. */ -interface ObjectWriter { +internal fun interface ObjectWriter { - /** - * The type of the API object this writer is responsible for. - */ - val objectType: ApiObjectType - - /** - * Writes the API object to the provided builder using data from the BPMN model API. - */ - fun write(builder: T, modelApi: BpmnModelApi) - - /** - * Determines if the writer should write the object to the process-api. - */ - fun shouldWrite(modelApi: BpmnModelApi): Boolean -} \ No newline at end of file + fun addTo(builder: T, modelApi: BpmnModelApi) +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/EngineDetector.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/EngineDetector.kt index 62ef6d2a..0c18c0bf 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/EngineDetector.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/EngineDetector.kt @@ -9,7 +9,7 @@ import io.miragon.bpmn.domain.shared.ProcessEngine * It returns `null` for plain BPMN files that carry no engine extensions. * The result is used to warn when the selected engine does not match the model's actual target. */ -object EngineDetector { +internal object EngineDetector { private const val ZEEBE_NS = "http://camunda.org/schema/zeebe/" private const val OPERATON_NS = "http://operaton.org/schema/" diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/ExtractBpmnAdapter.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/ExtractBpmnAdapter.kt index 17f49e7d..f9122395 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/ExtractBpmnAdapter.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/ExtractBpmnAdapter.kt @@ -1,28 +1,26 @@ package io.miragon.bpmn.adapter.outbound.engine -import io.miragon.bpmn.adapter.outbound.engine.extractor.Camunda7ModelExtractor -import io.miragon.bpmn.adapter.outbound.engine.extractor.EngineSpecificExtractor -import io.miragon.bpmn.adapter.outbound.engine.extractor.OperatonModelExtractor -import io.miragon.bpmn.adapter.outbound.engine.extractor.ZeebeModelExtractor -import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort import io.github.oshai.kotlinlogging.KotlinLogging -import io.miragon.bpmn.domain.BpmnModel +import io.miragon.bpmn.adapter.outbound.engine.dialect.CamundaDialect +import io.miragon.bpmn.adapter.outbound.engine.dialect.EngineDialect +import io.miragon.bpmn.adapter.outbound.engine.dialect.ZeebeDialect +import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort import io.miragon.bpmn.domain.BpmnResource +import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.ProcessEngine -class ExtractBpmnAdapter( - private val extractors: Map = ExtractBpmnAdapter.extractors +internal class ExtractBpmnAdapter( + private val dialects: Map = ExtractBpmnAdapter.dialects ) : ExtractBpmnPort { override fun extract( bpmnFile: BpmnResource, engine: ProcessEngine, - ): BpmnModel { - val content = bpmnFile.content - val extractor = getExtractor(engine) + ): ProcessModel { + val dialect = dialects[engine] ?: error("No dialect found for engine: $engine") return try { - logger.info { "Extracting model '${bpmnFile.fileName}' with extractor for '$engine'" } - extractor.extract(content) + logger.info { "Extracting model '${bpmnFile.fileName}' for '$engine'" } + ProcessModelReader(dialect).read(bpmnFile.content) } catch (ex: IllegalStateException) { throw IllegalStateException( "Failed to extract file: ${bpmnFile.fileName}. Please check its a valid file for $engine", @@ -36,17 +34,23 @@ class ExtractBpmnAdapter( } } - private fun getExtractor(engine: ProcessEngine): EngineSpecificExtractor { - return extractors[engine] - ?: error("No extractor found for engine: $engine") - } - companion object { + private const val CAMUNDA_7_NAMESPACE = "http://camunda.org/schema/1.0/bpmn" + private const val OPERATON_NAMESPACE = "http://operaton.org/schema/1.0/bpmn" + private val logger = KotlinLogging.logger {} - val extractors = mapOf( - ProcessEngine.ZEEBE to ZeebeModelExtractor(), - ProcessEngine.CAMUNDA_7 to Camunda7ModelExtractor(), - ProcessEngine.OPERATON to OperatonModelExtractor() + + /** + * The engine registry (ADR 004). Reading a BPMN file is the same work for every engine apart from + * its own namespace, so a new engine contributes a dialect here rather than its own reader. + * + * Camunda 7 and Operaton share the identical element and attribute vocabulary and differ only in + * that namespace, so both use the same dialect with their own value (see ADR 010). + */ + val dialects = mapOf( + ProcessEngine.ZEEBE to ZeebeDialect(), + ProcessEngine.CAMUNDA_7 to CamundaDialect(CAMUNDA_7_NAMESPACE), + ProcessEngine.OPERATON to CamundaDialect(OPERATON_NAMESPACE), ) } -} \ No newline at end of file +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/ProcessModelReader.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/ProcessModelReader.kt new file mode 100644 index 00000000..1ec139d5 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/ProcessModelReader.kt @@ -0,0 +1,38 @@ +package io.miragon.bpmn.adapter.outbound.engine + +import io.miragon.bpmn.adapter.outbound.engine.bpmn.BpmnDefinitionsReader.extractVariantName +import io.miragon.bpmn.adapter.outbound.engine.bpmn.BpmnDefinitionsReader.getProcessId +import io.miragon.bpmn.adapter.outbound.engine.bpmn.BpmnDefinitionsReader.getProcessName +import io.miragon.bpmn.adapter.outbound.engine.bpmn.BpmnDefinitionsReader.isExecutable +import io.miragon.bpmn.adapter.outbound.engine.bpmn.BpmnDefinitionsReader.readRootElements +import io.miragon.bpmn.adapter.outbound.engine.bpmn.BpmnStructureReader +import io.miragon.bpmn.adapter.outbound.engine.dialect.EngineDialect +import io.miragon.bpmn.adapter.outbound.engine.xml.SecureBpmnParser +import io.miragon.bpmn.domain.ProcessModel + +/** + * Reads a [ProcessModel] from raw BPMN bytes. + * + * The read is split along the only axis that varies between engines. A BPMN file has two halves: the + * `bpmn:Process` scope tree, walked by [BpmnStructureReader], and the surrounding `bpmn:Definitions` + * metadata and root elements, read by `BpmnDefinitionsReader`. Both are pure BPMN and identical for every + * engine; everything living in an engine's own namespace comes from the [EngineDialect]. Supporting + * another engine therefore means passing a different dialect, not writing another reader (see ADR 004). + */ +internal class ProcessModelReader(private val dialect: EngineDialect) { + + fun read(bytes: ByteArray): ProcessModel { + val modelInstance = SecureBpmnParser.readModelFromBytes(bytes) + val (flowNodes, sequenceFlows) = BpmnStructureReader(modelInstance, dialect).read() + return ProcessModel( + flowNodes = flowNodes, + sequenceFlows = sequenceFlows, + processId = modelInstance.getProcessId(), + processName = modelInstance.getProcessName(), + definitions = modelInstance.readRootElements(dialect::correlationKeyOf), + isExecutable = modelInstance.isExecutable(), + detectedEngine = EngineDetector.detect(bytes.decodeToString()), + variantName = modelInstance.extractVariantName(), + ) + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnDefinitionsReader.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnDefinitionsReader.kt new file mode 100644 index 00000000..5304e247 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnDefinitionsReader.kt @@ -0,0 +1,89 @@ +package io.miragon.bpmn.adapter.outbound.engine.bpmn + +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.findExtensionElements +import io.miragon.bpmn.domain.shared.RootElementDefinition +import io.miragon.bpmn.domain.shared.RootElements +import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants +import org.camunda.bpm.model.bpmn.instance.Error +import org.camunda.bpm.model.bpmn.instance.Escalation +import org.camunda.bpm.model.bpmn.instance.Message +import org.camunda.bpm.model.bpmn.instance.Process +import org.camunda.bpm.model.bpmn.instance.Signal +import org.camunda.bpm.model.xml.ModelInstance +import org.camunda.bpm.model.xml.instance.ModelElementInstance + +/** + * Process-level metadata and the `bpmn:Definitions` root-element registries — the parts of a BPMN file + * that are identical across engines. Per-node structure is read by `BpmnStructureReader`. + */ +internal object BpmnDefinitionsReader { + + fun ModelInstance.getProcessId(): String { + val process = this.findProcess() + val processId = process.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) + requireNotNull(processId) { "Process element is missing an 'id' attribute" } + return processId + } + + fun ModelInstance.getProcessName(): String? { + val raw = this.findProcess().getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_NAME) + return raw?.normalizeWhitespace()?.takeIf { it.isNotBlank() } + } + + fun ModelInstance.isExecutable(): Boolean { + val process = this.findProcess() + val raw = process.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_IS_EXECUTABLE) + return raw?.toBoolean() ?: true + } + + fun ModelInstance.extractVariantName(): String? { + val process = this.findProcess() + val extensions = process.findExtensionElements() + val propertiesContainers = extensions.filter { it.domElement.localName == "properties" } + val allProperties = propertiesContainers.flatMap { it.domElement.childElements } + val variantProperty = allProperties + .filter { it.localName == "property" } + .firstOrNull { it.getAttribute("name") == BpmnExtensionConstants.VARIANT_NAME_PROPERTY_NAME } + return variantProperty?.getAttribute("value")?.takeIf { it.isNotBlank() } + } + + fun ModelInstance.findProcess(): Process { + val process = this.getModelElementsByType(Process::class.java).firstOrNull() + requireNotNull(process) { "BPMN model does not contain a Process element" } + return process + } + + /** + * The `bpmn:Definitions` root-element registries, each keyed by the element's own id — several events + * may reference the same message, signal, error or escalation. + * + * [correlationKeyOf] resolves the engine's correlation-key expression, which BPMN declares on the + * message element rather than on the events referencing it. It is the only engine-specific part of a + * root element, which is why it arrives as a function instead of pulling the whole dialect in here. + */ + fun ModelInstance.readRootElements(correlationKeyOf: (Message) -> String?): RootElements { + return RootElements( + messages = registryOf(Message::class.java) { + RootElementDefinition.Message(id = it.id ?: it.name, name = it.name, correlationKey = correlationKeyOf(it)) + }, + signals = registryOf(Signal::class.java) { + RootElementDefinition.Signal(id = it.id ?: it.name, name = it.name) + }, + errors = registryOf(Error::class.java) { + RootElementDefinition.Error(id = it.id ?: it.name, name = it.name, code = it.errorCode) + }, + escalations = registryOf(Escalation::class.java) { + RootElementDefinition.Escalation(id = it.id ?: it.name, name = it.name, code = it.escalationCode) + }, + ) + } + + private fun ModelInstance.registryOf( + type: Class, + toDefinition: (E) -> D, + ): List { + return getModelElementsByType(type).map(toDefinition).distinctBy { it.id } + } + + fun String.normalizeWhitespace(): String = this.replace(Regex("\\s+"), " ").trim() +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/BpmnExtensionConstants.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnExtensionConstants.kt similarity index 59% rename from bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/BpmnExtensionConstants.kt rename to bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnExtensionConstants.kt index 889ee476..ca983b0f 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/BpmnExtensionConstants.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnExtensionConstants.kt @@ -1,9 +1,9 @@ -package io.miragon.bpmn.adapter.outbound.engine.constants +package io.miragon.bpmn.adapter.outbound.engine.bpmn /** * Extension property names that apply across all process engines. */ -object BpmnExtensionConstants { +internal object BpmnExtensionConstants { const val VARIANT_NAME_PROPERTY_NAME = "variantName" } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnStructureReader.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnStructureReader.kt new file mode 100644 index 00000000..4c543e91 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/bpmn/BpmnStructureReader.kt @@ -0,0 +1,359 @@ +package io.miragon.bpmn.adapter.outbound.engine.bpmn + +import io.miragon.bpmn.adapter.outbound.engine.bpmn.BpmnDefinitionsReader.findProcess +import io.miragon.bpmn.adapter.outbound.engine.bpmn.BpmnDefinitionsReader.normalizeWhitespace +import io.miragon.bpmn.adapter.outbound.engine.dialect.EngineDialect +import io.miragon.bpmn.adapter.outbound.engine.xml.ForeignXmlReader +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.FlowScope +import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.MessageReference +import io.miragon.bpmn.domain.shared.MultiInstanceDefinition +import io.miragon.bpmn.domain.shared.SequenceFlowDefinition +import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.shared.TimerType +import org.camunda.bpm.model.bpmn.instance.Activity +import org.camunda.bpm.model.bpmn.instance.BoundaryEvent +import org.camunda.bpm.model.bpmn.instance.BusinessRuleTask +import org.camunda.bpm.model.bpmn.instance.CallActivity +import org.camunda.bpm.model.bpmn.instance.CatchEvent +import org.camunda.bpm.model.bpmn.instance.CompensateEventDefinition +import org.camunda.bpm.model.bpmn.instance.ComplexGateway +import org.camunda.bpm.model.bpmn.instance.ConditionalEventDefinition +import org.camunda.bpm.model.bpmn.instance.EndEvent +import org.camunda.bpm.model.bpmn.instance.ErrorEventDefinition +import org.camunda.bpm.model.bpmn.instance.EscalationEventDefinition +import org.camunda.bpm.model.bpmn.instance.EventBasedGateway +import org.camunda.bpm.model.bpmn.instance.EventDefinition +import org.camunda.bpm.model.bpmn.instance.ExclusiveGateway +import org.camunda.bpm.model.bpmn.instance.FlowElement +import org.camunda.bpm.model.bpmn.instance.FlowNode +import org.camunda.bpm.model.bpmn.instance.Gateway +import org.camunda.bpm.model.bpmn.instance.InclusiveGateway +import org.camunda.bpm.model.bpmn.instance.IntermediateCatchEvent +import org.camunda.bpm.model.bpmn.instance.IntermediateThrowEvent +import org.camunda.bpm.model.bpmn.instance.LinkEventDefinition +import org.camunda.bpm.model.bpmn.instance.ManualTask +import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition +import org.camunda.bpm.model.bpmn.instance.MultiInstanceLoopCharacteristics +import org.camunda.bpm.model.bpmn.instance.ParallelGateway +import org.camunda.bpm.model.bpmn.instance.ReceiveTask +import org.camunda.bpm.model.bpmn.instance.ScriptTask +import org.camunda.bpm.model.bpmn.instance.SendTask +import org.camunda.bpm.model.bpmn.instance.SequenceFlow +import org.camunda.bpm.model.bpmn.instance.ServiceTask +import org.camunda.bpm.model.bpmn.instance.SignalEventDefinition +import org.camunda.bpm.model.bpmn.instance.StartEvent +import org.camunda.bpm.model.bpmn.instance.SubProcess +import org.camunda.bpm.model.bpmn.instance.Task +import org.camunda.bpm.model.bpmn.instance.TerminateEventDefinition +import org.camunda.bpm.model.bpmn.instance.TimerEventDefinition +import org.camunda.bpm.model.bpmn.instance.Transaction +import org.camunda.bpm.model.bpmn.instance.UserTask +import org.camunda.bpm.model.xml.ModelInstance + +/** + * Reads the engine-independent BPMN structure of a process into the domain's scope tree. + * + * A `bpmn:FlowElementsContainer` — the process itself, or any sub-process — owns both its flow nodes and + * its sequence flows, so the resulting tree mirrors `bpmn:FlowElementsContainer.flowElements` and every + * flow knows the scope it belongs to. Everything engine-specific is delegated to [EngineDialect]. + */ +@Suppress("TooManyFunctions") +internal class BpmnStructureReader( + private val model: ModelInstance, + private val dialect: EngineDialect, +) { + + private val extensionReader = ForeignXmlReader(model, dialect.namespace, dialect.fullyReadExtensions) + + private val boundaryEventsByHost: Map> by lazy { + model.getModelElementsByType(BoundaryEvent::class.java) + .mapNotNull { event -> event.attachedTo?.id?.let { host -> host to event.id } } + .filter { (_, eventId) -> eventId != null } + .groupBy({ it.first }, { it.second }) + } + + /** + * Default-flow ids, collected once. BPMN puts `default` on the *source* element, which is also where + * the domain model keeps it; sequence flows carry the derived flag for the generated `Flows` object. + */ + private val defaultFlowIds: Set by lazy { + val fromExclusive = model.getModelElementsByType(ExclusiveGateway::class.java).mapNotNull { it.default?.id } + val fromInclusive = model.getModelElementsByType(InclusiveGateway::class.java).mapNotNull { it.default?.id } + val fromActivities = model.getModelElementsByType(Activity::class.java).mapNotNull { it.default?.id } + (fromExclusive + fromInclusive + fromActivities).toSet() + } + + /** + * The root scope of the `bpmn:Process`. Nested scopes are reachable through the sub-process nodes + * that own them. + */ + fun read(): FlowScope = readScope(model.findProcess().flowElements) + + private fun readScope(elements: Collection): FlowScope { + return FlowScope( + flowNodes = elements.filterIsInstance().map { it.toDefinition() }, + sequenceFlows = elements.filterIsInstance().mapNotNull { it.toDefinition() }, + ) + } + + private fun FlowNode.toDefinition(): FlowNodeDefinition = when (this) { + is SubProcess -> toSubProcess() + is CallActivity -> toCallActivity() + is Gateway -> toGateway() + is CatchEvent, is org.camunda.bpm.model.bpmn.instance.ThrowEvent -> toEvent() + is Task -> toTask() + else -> FlowNodeDefinition.Unknown( + id = id, + displayName = displayName(), + incoming = incomingFlowIds(), + outgoing = outgoingFlowIds(), + variables = dialect.variablesOf(this), + extensions = extensionReader.extensionsOf(id), + engineAttributes = extensionReader.foreignAttributesOf(id, dialect.fullyReadAttributesOf(this)), + ) + } + + private fun SubProcess.toSubProcess(): FlowNodeDefinition.Activity.SubProcess { + val (children, childFlows) = readScope(flowElements) + return FlowNodeDefinition.Activity.SubProcess( + id = id, + kind = subProcessKind(), + displayName = displayName(), + incoming = incomingFlowIds(), + outgoing = outgoingFlowIds(), + flowNodes = children, + sequenceFlows = childFlows, + multiInstance = multiInstance(), + ioMapping = dialect.ioMappingOf(this), + boundaryEventRefs = boundaryEventRefs(), + isForCompensation = isForCompensation, + defaultFlow = defaultFlowId(), + variables = dialect.variablesOf(this), + extensions = extensionReader.extensionsOf(id), + engineAttributes = extensionReader.foreignAttributesOf(id, dialect.fullyReadAttributesOf(this)), + ) + } + + private fun CallActivity.toCallActivity(): FlowNodeDefinition.Activity.CallActivity { + return FlowNodeDefinition.Activity.CallActivity( + id = id, + definition = dialect.callActivityOf(this), + displayName = displayName(), + incoming = incomingFlowIds(), + outgoing = outgoingFlowIds(), + multiInstance = multiInstance(), + ioMapping = dialect.ioMappingOf(this), + boundaryEventRefs = boundaryEventRefs(), + isForCompensation = isForCompensation, + defaultFlow = defaultFlowId(), + variables = dialect.variablesOf(this), + extensions = extensionReader.extensionsOf(id), + engineAttributes = extensionReader.foreignAttributesOf(id, dialect.fullyReadAttributesOf(this)), + ) + } + + private fun Task.toTask(): FlowNodeDefinition.Activity.Task { + return FlowNodeDefinition.Activity.Task( + id = id, + kind = taskKind(), + displayName = displayName(), + incoming = incomingFlowIds(), + outgoing = outgoingFlowIds(), + implementation = dialect.implementationOf(this), + message = taskMessage(), + multiInstance = multiInstance(), + ioMapping = dialect.ioMappingOf(this), + boundaryEventRefs = boundaryEventRefs(), + isForCompensation = isForCompensation, + defaultFlow = defaultFlowId(), + variables = dialect.variablesOf(this), + extensions = extensionReader.extensionsOf(id), + engineAttributes = extensionReader.foreignAttributesOf(id, dialect.fullyReadAttributesOf(this)), + ) + } + + private fun Gateway.toGateway(): FlowNodeDefinition.Gateway { + return FlowNodeDefinition.Gateway( + id = id, + kind = gatewayKind(), + displayName = displayName(), + incoming = incomingFlowIds(), + outgoing = outgoingFlowIds(), + defaultFlow = defaultFlowId(), + variables = dialect.variablesOf(this), + extensions = extensionReader.extensionsOf(id), + engineAttributes = extensionReader.foreignAttributesOf(id, dialect.fullyReadAttributesOf(this)), + ) + } + + private fun FlowNode.toEvent(): FlowNodeDefinition.Event { + return FlowNodeDefinition.Event( + id = id, + shape = eventShape(), + displayName = displayName(), + incoming = incomingFlowIds(), + outgoing = outgoingFlowIds(), + eventDefinitions = eventDefinitions(), + attachedToRef = (this as? BoundaryEvent)?.attachedTo?.id, + interrupting = interrupting(), + implementation = dialect.implementationOf(this), + ioMapping = dialect.ioMappingOf(this), + variables = dialect.variablesOf(this), + extensions = extensionReader.extensionsOf(id), + engineAttributes = extensionReader.foreignAttributesOf(id, dialect.fullyReadAttributesOf(this)), + ) + } + + private fun SequenceFlow.toDefinition(): SequenceFlowDefinition? { + val sourceRef = source?.id ?: return null + val targetRef = target?.id ?: return null + return SequenceFlowDefinition( + id = id, + sourceRef = sourceRef, + targetRef = targetRef, + flowName = displayName(), + conditionExpression = conditionExpression?.textContent?.takeIf { it.isNotBlank() }, + isDefault = id != null && id in defaultFlowIds, + ) + } + + private fun FlowNode.displayName(): String? = name?.normalizeWhitespace()?.takeIf { it.isNotBlank() } + + private fun SequenceFlow.displayName(): String? = name?.normalizeWhitespace()?.takeIf { it.isNotBlank() } + + private fun FlowNode.incomingFlowIds(): List = incoming.mapNotNull { it.id } + + private fun FlowNode.outgoingFlowIds(): List = outgoing.mapNotNull { it.id } + + private fun FlowNode.boundaryEventRefs(): List = id?.let { boundaryEventsByHost[it] }.orEmpty() + + private fun FlowNode.defaultFlowId(): String? = when (this) { + is ExclusiveGateway -> default?.id + is InclusiveGateway -> default?.id + is Activity -> default?.id + else -> null + } + + private fun Activity.multiInstance(): MultiInstanceDefinition? { + val loop = loopCharacteristics as? MultiInstanceLoopCharacteristics ?: return null + val base = MultiInstanceDefinition( + sequential = loop.isSequential, + cardinality = loop.loopCardinality?.textContent?.takeIf { it.isNotBlank() }, + completionCondition = loop.completionCondition?.textContent?.takeIf { it.isNotBlank() }, + ) + return dialect.multiInstanceBindingsOf(loop, base) + } + + private fun Task.taskMessage(): MessageReference? = when (this) { + is ReceiveTask -> message?.toReference() + is SendTask -> message?.toReference() + else -> null + } + + private fun org.camunda.bpm.model.bpmn.instance.Message.toReference(): MessageReference { + return MessageReference( + messageRef = id ?: name, + messageName = name, + ) + } + + private fun FlowNode.eventDefinitions(): List { + return getChildElementsByType(EventDefinition::class.java).mapNotNull { it.toInstance() } + } + + @Suppress("CyclomaticComplexMethod") + private fun EventDefinition.toInstance(): EventDefinitionInstance? = when (this) { + is TimerEventDefinition -> toTimer() + is MessageEventDefinition -> EventDefinitionInstance.Message( + reference = message?.toReference() ?: MessageReference(), + ) + + is SignalEventDefinition -> EventDefinitionInstance.Signal( + signalRef = signal?.let { it.id ?: it.name }, + signalName = signal?.name, + ) + + is ErrorEventDefinition -> EventDefinitionInstance.Error( + errorRef = error?.let { it.id ?: it.name }, + errorName = error?.name, + errorCode = error?.errorCode, + ) + + is EscalationEventDefinition -> EventDefinitionInstance.Escalation( + escalationRef = escalation?.let { it.id ?: it.name }, + escalationName = escalation?.name, + escalationCode = escalation?.escalationCode, + ) + + is CompensateEventDefinition -> EventDefinitionInstance.Compensation( + activityRef = activity?.id, + waitForCompletion = isWaitForCompletion, + ) + + is ConditionalEventDefinition -> EventDefinitionInstance.Conditional( + expression = condition?.textContent?.takeIf { it.isNotBlank() }, + ) + + is LinkEventDefinition -> EventDefinitionInstance.Link(linkName = name) + is TerminateEventDefinition -> EventDefinitionInstance.Terminate + else -> null + } + + private fun TimerEventDefinition.toTimer(): EventDefinitionInstance.Timer = when { + timeDate != null -> EventDefinitionInstance.Timer(TimerType.DATE, timeDate.textContent) + timeDuration != null -> EventDefinitionInstance.Timer(TimerType.DURATION, timeDuration.textContent) + timeCycle != null -> EventDefinitionInstance.Timer(TimerType.CYCLE, timeCycle.textContent) + else -> EventDefinitionInstance.Timer() + } + + /** + * Whether the event interrupts its enclosing scope, defaulting to `true` per the BPMN spec when the + * attribute is absent. Meaningful only for boundary events (`cancelActivity`) and event sub-process + * start events (`isInterrupting`); `null` for every other event. + */ + private fun FlowNode.interrupting(): Boolean? = when { + this is BoundaryEvent -> cancelActivity() + this is StartEvent && (parentElement as? SubProcess)?.triggeredByEvent() == true -> isInterrupting + else -> null + } + + private fun FlowNode.eventShape(): EventShape = when (this) { + is BoundaryEvent -> EventShape.BOUNDARY_EVENT + is StartEvent -> EventShape.START_EVENT + is EndEvent -> EventShape.END_EVENT + is IntermediateCatchEvent -> EventShape.INTERMEDIATE_CATCH_EVENT + is IntermediateThrowEvent -> EventShape.INTERMEDIATE_THROW_EVENT + else -> EventShape.INTERMEDIATE_CATCH_EVENT + } + + private fun SubProcess.subProcessKind(): SubProcessKind = when { + this is Transaction -> SubProcessKind.TRANSACTION + triggeredByEvent() -> SubProcessKind.EVENT + else -> SubProcessKind.PLAIN + } + + private fun Task.taskKind(): TaskKind = when (this) { + is ServiceTask -> TaskKind.SERVICE + is UserTask -> TaskKind.USER + is ReceiveTask -> TaskKind.RECEIVE + is SendTask -> TaskKind.SEND + is ScriptTask -> TaskKind.SCRIPT + is ManualTask -> TaskKind.MANUAL + is BusinessRuleTask -> TaskKind.BUSINESS_RULE + else -> TaskKind.NONE + } + + private fun Gateway.gatewayKind(): GatewayKind = when (this) { + is ExclusiveGateway -> GatewayKind.EXCLUSIVE + is ParallelGateway -> GatewayKind.PARALLEL + is InclusiveGateway -> GatewayKind.INCLUSIVE + is EventBasedGateway -> GatewayKind.EVENT_BASED + is ComplexGateway -> GatewayKind.COMPLEX + else -> GatewayKind.EXCLUSIVE + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/CamundaDialect.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/CamundaDialect.kt new file mode 100644 index 00000000..f6e26b05 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/CamundaDialect.kt @@ -0,0 +1,201 @@ +package io.miragon.bpmn.adapter.outbound.engine.dialect + +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.extractAttribute +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.filterByType +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.findExtensionElements +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.withAttribute +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.withElementName +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.IoMapping +import io.miragon.bpmn.domain.shared.MultiInstanceDefinition +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.VariableDefinition +import io.miragon.bpmn.domain.shared.VariableDirection +import io.miragon.bpmn.domain.utils.StringUtils.removeExpressionSyntax +import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants +import org.camunda.bpm.model.bpmn.instance.CallActivity +import org.camunda.bpm.model.bpmn.instance.FlowNode +import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition +import org.camunda.bpm.model.bpmn.instance.MultiInstanceLoopCharacteristics +import org.camunda.bpm.model.bpmn.instance.ServiceTask +import org.camunda.bpm.model.xml.instance.DomElement +import org.camunda.bpm.model.xml.instance.ModelElementInstance + +/** + * Reads the Camunda-7-style half of a BPMN model. Camunda 7 and Operaton share the identical element and + * attribute vocabulary and differ only in their XML namespace, so both engines use this reader with their + * own [namespace] (see ADR 010). + */ +@Suppress("TooManyFunctions") +internal class CamundaDialect(override val namespace: String) : EngineDialect { + + /** + * Empty on purpose. Camunda's extension vocabulary is richer than what the typed fields capture: + * `camunda:inputParameter` may nest a `camunda:script`, `camunda:map` or `camunda:list` where only + * the text body is read; `camunda:in`/`out` carry `businessKey` and `local` beyond the mapping; and + * `camunda:properties` is read for two specific property names only. Reporting these raw is what + * keeps that information reachable. + */ + override val fullyReadExtensions = emptySet() + + /** + * Service tasks and message throw events are both `camunda:ServiceTaskLike`. A service task always + * reports an implementation — [TaskImplementation.Unspecified] when nothing is configured, so the + * missing-implementation rule can flag it — while other nodes only do when one is actually set. + */ + override fun implementationOf(node: FlowNode): TaskImplementation? { + if (node is ServiceTask) return node.attributeImplementation()?.implementation ?: TaskImplementation.Unspecified + return node.getChildElementsByType(MessageEventDefinition::class.java) + .firstNotNullOfOrNull { it.attributeImplementation() } + ?.implementation + } + + override fun fullyReadAttributesOf(node: FlowNode): Set { + val resolved = when (node) { + is ServiceTask -> node.attributeImplementation() + else -> node.getChildElementsByType(MessageEventDefinition::class.java) + .firstNotNullOfOrNull { it.attributeImplementation() } + } + return setOfNotNull(resolved?.attributeName) + } + + override fun ioMappingOf(node: FlowNode): IoMapping? { + val parameters = node.findExtensionElements() + .filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_INPUT_OUTPUT) + .flatMap { it.domElement.childElements } + val inputs = parameters.withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_INPUT_PARAMETER).mapNotNull { it.toParameter() } + val outputs = parameters.withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_OUTPUT_PARAMETER).mapNotNull { it.toParameter() } + return IoMapping(inputs, outputs).takeUnless { it.isEmpty() } + } + + override fun multiInstanceBindingsOf( + loop: MultiInstanceLoopCharacteristics, + base: MultiInstanceDefinition, + ): MultiInstanceDefinition { + return base.copy( + inputCollection = loop.attribute(CamundaModelConstants.COLLECTION_ATTRIBUTE), + inputElement = loop.attribute(CamundaModelConstants.ELEMENT_VARIABLE_ATTRIBUTE), + ) + } + + override fun variablesOf(node: FlowNode): List { + val extensions = node.findExtensionElements() + val ioMapping = ioMappingOf(node) + val ioVariables = ioMapping?.inputs.orEmpty().map { Triple(it.target, VariableDirection.INPUT, it.source) } + + ioMapping?.outputs.orEmpty().map { Triple(it.target, VariableDirection.OUTPUT, it.source) } + val allVariables = ioVariables + + node.multiInstanceVariables() + + extensions.callActivityMappingVariables() + + extensions.additionalVariables() + return allVariables + .map { (name, direction, expression) -> Triple(name.removeExpressionSyntax(), direction, expression) } + .distinct() + .map { (name, direction, expression) -> VariableDefinition(name, direction, expression) } + } + + override fun callActivityOf(callActivity: CallActivity): CallActivityDefinition { + val extensions = callActivity.findExtensionElements() + return CallActivityDefinition( + id = callActivity.id, + calledElement = callActivity.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_CALLED_ELEMENT), + mappings = extensions.toCallActivityMappings(), + propagateAllInputVariables = extensions.propagatesAll(BpmnModelConstants.CAMUNDA_ELEMENT_IN), + propagateAllOutputVariables = extensions.propagatesAll(BpmnModelConstants.CAMUNDA_ELEMENT_OUT), + ) + } + + /** + * The implementation attributes in precedence order. Declared once so the resolved value and the + * name of the attribute it came from can never disagree. + */ + private val implementationAttributes: List TaskImplementation>> = listOf( + BpmnModelConstants.CAMUNDA_ATTRIBUTE_TOPIC to { value -> TaskImplementation.ExternalTask(value) }, + BpmnModelConstants.CAMUNDA_ATTRIBUTE_DELEGATE_EXPRESSION to { value -> TaskImplementation.DelegateExpression(value) }, + BpmnModelConstants.CAMUNDA_ATTRIBUTE_CLASS to { value -> TaskImplementation.JavaClass(value) }, + BpmnModelConstants.CAMUNDA_ATTRIBUTE_EXPRESSION to { value -> TaskImplementation.Expression(value) }, + ) + + private data class ResolvedImplementation(val attributeName: String, val implementation: TaskImplementation) + + private fun ModelElementInstance.attributeImplementation(): ResolvedImplementation? { + return implementationAttributes.firstNotNullOfOrNull { (name, build) -> + attribute(name)?.let { ResolvedImplementation(name, build(it)) } + } + } + + private fun ModelElementInstance.attribute(name: String): String? { + return getAttributeValueNs(namespace, name)?.takeIf { it.isNotBlank() } + } + + private fun FlowNode.multiInstanceVariables(): List> { + return getChildElementsByType(MultiInstanceLoopCharacteristics::class.java) + .flatMap { loop -> + listOfNotNull( + loop.attribute(CamundaModelConstants.COLLECTION_ATTRIBUTE), + loop.attribute(CamundaModelConstants.ELEMENT_VARIABLE_ATTRIBUTE), + ) + } + .map { Triple(it, VariableDirection.INPUT, it) } + } + + private fun List.callActivityMappingVariables(): List> { + val inElements = filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_IN) + val outElements = filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_OUT) + val sources = inElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE) + val sourceExpressions = inElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE_EXPRESSION) + val targets = outElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_TARGET) + return (sources + sourceExpressions).map { Triple(it, VariableDirection.INPUT, it) } + + targets.map { Triple(it, VariableDirection.OUTPUT, it) } + } + + private fun List.additionalVariables(): List> { + val properties = filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_PROPERTIES) + .flatMap { it.domElement.childElements } + .withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_PROPERTY) + val inputs = properties.valuesOfProperty(CamundaModelConstants.ADDITIONAL_INPUT_VARIABLES_PROPERTY_NAME) + val outputs = properties.valuesOfProperty(CamundaModelConstants.ADDITIONAL_OUTPUT_VARIABLES_PROPERTY_NAME) + return inputs.map { Triple(it, VariableDirection.INPUT, null) } + + outputs.map { Triple(it, VariableDirection.OUTPUT, null) } + } + + private fun List.valuesOfProperty(propertyName: String): List { + return withAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_NAME to propertyName) + .mapNotNull { it.getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_VALUE) } + .flatMap { it.split(",") } + .map { it.trim() } + .filter { it.isNotBlank() } + } + + private fun List.toCallActivityMappings(): List { + val inputs = filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_IN) + .mapNotNull { it.domElement.toCallActivityMapping(VariableDirection.INPUT) } + val outputs = filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_OUT) + .mapNotNull { it.domElement.toCallActivityMapping(VariableDirection.OUTPUT) } + return inputs + outputs + } + + private fun List.propagatesAll(elementType: String): Boolean? { + val propagatesAll = filterByType(elementType).any { + it.domElement.getAttribute(CamundaModelConstants.VARIABLES_ATTRIBUTE) == CamundaModelConstants.VARIABLES_ALL_VALUE + } + return if (propagatesAll) true else null + } + + private fun DomElement.toCallActivityMapping(direction: VariableDirection): CallActivityDefinition.Mapping? { + val source = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE)?.takeIf { it.isNotBlank() } + val sourceExpression = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE_EXPRESSION)?.takeIf { it.isNotBlank() } + val target = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_TARGET)?.takeIf { it.isNotBlank() } + if (source == null && sourceExpression == null && target == null) return null + return CallActivityDefinition.Mapping(direction, source, sourceExpression, target) + } + + /** + * `camunda:inputParameter` / `camunda:outputParameter` carry the variable name in their `name` + * attribute and the bound expression as their text content, e.g. `${'$'}{execution.getVariable('x')}`. + */ + private fun DomElement.toParameter(): IoMapping.Parameter? { + val target = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_NAME)?.takeIf { it.isNotBlank() } ?: return null + val source = textContent?.trim()?.takeIf { it.isNotBlank() } + return IoMapping.Parameter(target = target, source = source) + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/CamundaModelConstants.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/CamundaModelConstants.kt similarity index 58% rename from bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/CamundaModelConstants.kt rename to bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/CamundaModelConstants.kt index 66ede074..d0e8d597 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/CamundaModelConstants.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/CamundaModelConstants.kt @@ -1,12 +1,10 @@ -package io.miragon.bpmn.adapter.outbound.engine.constants - -import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants +package io.miragon.bpmn.adapter.outbound.engine.dialect /** * Extends [org.camunda.bpm.model.bpmn.impl.BpmnModelConstants] with additional constants, * relevant for engines building up on Camunda 7 */ -object CamundaModelConstants { +internal object CamundaModelConstants { const val ADDITIONAL_INPUT_VARIABLES_PROPERTY_NAME = "additionalInputVariables" const val ADDITIONAL_OUTPUT_VARIABLES_PROPERTY_NAME = "additionalOutputVariables" @@ -14,8 +12,6 @@ object CamundaModelConstants { const val VARIABLES_ATTRIBUTE = "variables" const val VARIABLES_ALL_VALUE = "all" - val callActivityMappingElements = listOf( - BpmnModelConstants.CAMUNDA_ELEMENT_IN, - BpmnModelConstants.CAMUNDA_ELEMENT_OUT - ) + const val COLLECTION_ATTRIBUTE = "collection" + const val ELEMENT_VARIABLE_ATTRIBUTE = "elementVariable" } \ No newline at end of file diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/EngineDialect.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/EngineDialect.kt new file mode 100644 index 00000000..27a9d1b5 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/EngineDialect.kt @@ -0,0 +1,81 @@ +package io.miragon.bpmn.adapter.outbound.engine.dialect + +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.IoMapping +import io.miragon.bpmn.domain.shared.MultiInstanceDefinition +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.VariableDefinition +import org.camunda.bpm.model.bpmn.instance.CallActivity +import org.camunda.bpm.model.bpmn.instance.FlowNode +import org.camunda.bpm.model.bpmn.instance.Message +import org.camunda.bpm.model.bpmn.instance.MultiInstanceLoopCharacteristics + +/** + * The engine-specific half of BPMN extraction. + * + * `BpmnStructureReader` walks the standard BPMN structure — containment, sequence flows, event + * definitions, boundary attachments — which is identical for every engine. Everything that lives in an + * engine's own namespace (`zeebe:*`, `camunda:*`, `operaton:*`) is normalised here, so a new engine only + * has to implement this interface. See ADR 004 and ADR 017. + */ +internal interface EngineDialect { + + /** + * The engine's own XML namespace. + */ + val namespace: String + + /** + * Names of the `bpmn:extensionElements` children in [namespace] that this dialect reads **in full**. + * + * These are left out of a node's raw `extensions`, which exists to carry what is *not* normalised + * (ADR 018, layer 3) — emitting both would state the same fact twice and let the two drift apart. + * + * Membership is a claim about coverage, so an element belongs here only if every attribute and child + * it can carry ends up in a typed field. Partially read elements stay out and keep being reported raw. + */ + val fullyReadExtensions: Set + + /** + * Names of the foreign-namespace *attributes* on [node] that this dialect read into a typed field. + * These are left out of the node's `engineAttributes` for the same reason as [fullyReadExtensions]. + * + * Resolved per node rather than declared as a fixed set: an engine may offer several mutually + * exclusive attributes for one concept, and only the one that actually won is normalised. The others + * stay in the raw layer, which is where a model that declares two of them remains readable. + */ + fun fullyReadAttributesOf(node: FlowNode): Set + + /** + * The service-task-like implementation of [node], or `null` if the node has no such concept. + */ + fun implementationOf(node: FlowNode): TaskImplementation? + + /** + * Input/output parameter mapping of [node] (`zeebe:ioMapping` / `camunda:inputOutput`). + */ + fun ioMappingOf(node: FlowNode): IoMapping? + + /** + * Engine-specific collection/element bindings on top of the standard loop characteristics. + */ + fun multiInstanceBindingsOf( + loop: MultiInstanceLoopCharacteristics, + base: MultiInstanceDefinition + ): MultiInstanceDefinition + + /** + * Variables declared by [node], each tagged with its direction (see ADR 015). + */ + fun variablesOf(node: FlowNode): List + + /** + * The called-process binding of [callActivity], including the variables propagated in and out. + */ + fun callActivityOf(callActivity: CallActivity): CallActivityDefinition + + /** + * The correlation-key expression declared on [message], where the engine supports one. + */ + fun correlationKeyOf(message: Message): String? = null +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/ZeebeDialect.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/ZeebeDialect.kt new file mode 100644 index 00000000..94f95d95 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/ZeebeDialect.kt @@ -0,0 +1,157 @@ +package io.miragon.bpmn.adapter.outbound.engine.dialect + +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.filterByType +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.findExtensionElement +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.findExtensionElements +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.findExtensionElementsWithType +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.findFirstByType +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.nonBlankAttribute +import io.miragon.bpmn.adapter.outbound.engine.xml.CamundaXmlApi.nonBlankAttributeNs +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.IoMapping +import io.miragon.bpmn.domain.shared.MultiInstanceDefinition +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.VariableDefinition +import io.miragon.bpmn.domain.shared.VariableDirection +import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants +import org.camunda.bpm.model.bpmn.instance.CallActivity +import org.camunda.bpm.model.bpmn.instance.FlowNode +import org.camunda.bpm.model.bpmn.instance.Message +import org.camunda.bpm.model.bpmn.instance.MultiInstanceLoopCharacteristics +import org.camunda.bpm.model.xml.instance.DomElement +import org.camunda.bpm.model.xml.instance.ModelElementInstance + +/** + * Reads the `zeebe:` half of a BPMN model — Camunda 8 / Zeebe extension elements. + */ +internal class ZeebeDialect : EngineDialect { + + override val namespace = ZeebeModelConstants.NAMESPACE + + /** + * Zeebe's vocabulary for these four is small enough that the typed fields cover it completely: + * `taskDefinition` (type, retries), `ioMapping` (source/target per parameter), `loopCharacteristics` + * (both collections and both element variables) and `calledElement` (process id and both propagate + * flags). + */ + override val fullyReadExtensions = setOf( + ZeebeModelConstants.ELEMENT_TASK_DEFINITION, + ZeebeModelConstants.ELEMENT_IO_MAPPING, + ZeebeModelConstants.ELEMENT_LOOP_CHARACTERISTICS, + BpmnModelConstants.BPMN_ATTRIBUTE_CALLED_ELEMENT, + ) + + /** + * `zeebe:modelerTemplate` reaches [TaskImplementation.Connector] only when the node also has a + * `zeebe:taskDefinition`; on its own it is not read, so it is not claimed here either. + */ + override fun fullyReadAttributesOf(node: FlowNode): Set = when (implementationOf(node)) { + is TaskImplementation.Connector -> setOf(ZeebeModelConstants.ATTRIBUTE_MODELER_TEMPLATE) + else -> emptySet() + } + + override fun implementationOf(node: FlowNode): TaskImplementation? { + val taskDefinition = node.findExtensionElements().findFirstByType(ZeebeModelConstants.ELEMENT_TASK_DEFINITION) + ?: return null + val jobType = taskDefinition.nonBlankAttribute(BpmnModelConstants.BPMN_ATTRIBUTE_TYPE) + ?: return TaskImplementation.Unspecified + val retries = taskDefinition.nonBlankAttribute(ZeebeModelConstants.ATTRIBUTE_RETRIES) + val template = node.nonBlankAttributeNs(ZeebeModelConstants.NAMESPACE, ZeebeModelConstants.ATTRIBUTE_MODELER_TEMPLATE) + return when (template) { + null -> TaskImplementation.JobWorker(jobType, retries) + else -> TaskImplementation.Connector(jobType, template, retries) + } + } + + override fun ioMappingOf(node: FlowNode): IoMapping? { + val parameters = node.findExtensionElementsWithType(ZeebeModelConstants.ELEMENT_IO_MAPPING) + .flatMap { it.domElement.childElements } + val inputs = parameters.filter { it.localName == ZeebeModelConstants.ELEMENT_INPUT }.mapNotNull { it.toParameter() } + val outputs = parameters.filter { it.localName == ZeebeModelConstants.ELEMENT_OUTPUT }.mapNotNull { it.toParameter() } + return IoMapping(inputs, outputs).takeUnless { it.isEmpty() } + } + + override fun multiInstanceBindingsOf( + loop: MultiInstanceLoopCharacteristics, + base: MultiInstanceDefinition, + ): MultiInstanceDefinition { + val characteristics = loop.findExtensionElements() + .filterByType(ZeebeModelConstants.ELEMENT_LOOP_CHARACTERISTICS) + .firstOrNull() ?: return base + return base.copy( + inputCollection = characteristics.nonBlankAttribute(ZeebeModelConstants.ATTRIBUTE_INPUT_COLLECTION), + inputElement = characteristics.nonBlankAttribute(ZeebeModelConstants.ATTRIBUTE_INPUT_ELEMENT), + outputCollection = characteristics.nonBlankAttribute(ZeebeModelConstants.ATTRIBUTE_OUTPUT_COLLECTION), + outputElement = characteristics.nonBlankAttribute(ZeebeModelConstants.ATTRIBUTE_OUTPUT_ELEMENT), + ) + } + + override fun variablesOf(node: FlowNode): List { + val ioMapping = ioMappingOf(node) + val inputs = ioMapping?.inputs.orEmpty().map { Triple(it.target, VariableDirection.INPUT, it.source) } + val outputs = ioMapping?.outputs.orEmpty().map { Triple(it.target, VariableDirection.OUTPUT, it.source) } + val loopVariables = node.multiInstanceVariables() + return (inputs + outputs + loopVariables) + .distinct() + .map { (name, direction, expression) -> VariableDefinition(name, direction, expression) } + } + + override fun callActivityOf(callActivity: CallActivity): CallActivityDefinition { + val calledElement = callActivity.findExtensionElement(BpmnModelConstants.BPMN_ATTRIBUTE_CALLED_ELEMENT) + val mappings = ioMappingOf(callActivity) + return CallActivityDefinition( + id = callActivity.id, + calledElement = calledElement?.getAttributeValue(ZeebeModelConstants.ATTRIBUTE_PROCESS_ID), + mappings = mappings.toCallActivityMappings(), + propagateAllInputVariables = calledElement?.propagateFlag(ZeebeModelConstants.ATTRIBUTE_PROPAGATE_PARENT), + propagateAllOutputVariables = calledElement?.propagateFlag(ZeebeModelConstants.ATTRIBUTE_PROPAGATE_CHILD), + ) + } + + override fun correlationKeyOf(message: Message): String? { + val subscription = message.findExtensionElementsWithType(ZeebeModelConstants.ELEMENT_SUBSCRIPTION).firstOrNull() + return subscription?.getAttributeValue(ZeebeModelConstants.ATTRIBUTE_CORRELATION_KEY) + } + + private fun IoMapping?.toCallActivityMappings(): List { + val inputs = this?.inputs.orEmpty().map { CallActivityDefinition.Mapping( + direction = VariableDirection.INPUT, source = it.source, target = it.target) + } + val outputs = this?.outputs.orEmpty().map { CallActivityDefinition.Mapping(VariableDirection.OUTPUT, source = it.source, target = it.target) } + return inputs + outputs + } + + /** + * Multi-instance collections and element variables are process variables too. The `=`-prefixed FEEL + * form is the declaration; the variable name itself is the expression without that prefix. + */ + private fun FlowNode.multiInstanceVariables(): List> { + val characteristics = getChildElementsByType(MultiInstanceLoopCharacteristics::class.java) + .flatMap { it.findExtensionElements() } + .filterByType(ZeebeModelConstants.ELEMENT_LOOP_CHARACTERISTICS) + val inputs = characteristics.attributeValues( + ZeebeModelConstants.ATTRIBUTE_INPUT_ELEMENT, + ZeebeModelConstants.ATTRIBUTE_INPUT_COLLECTION, + ) + val outputs = characteristics.attributeValues( + ZeebeModelConstants.ATTRIBUTE_OUTPUT_ELEMENT, + ZeebeModelConstants.ATTRIBUTE_OUTPUT_COLLECTION, + ) + return inputs.map { Triple(it.removePrefix("="), VariableDirection.INPUT, it) } + + outputs.map { Triple(it.removePrefix("="), VariableDirection.OUTPUT, it) } + } + + private fun List.attributeValues(vararg names: String): List { + return names.flatMap { name -> mapNotNull { it.domElement.getAttribute(name) } } + } + + private fun DomElement.toParameter(): IoMapping.Parameter? { + val target = getAttribute(ZeebeModelConstants.ATTRIBUTE_TARGET)?.takeIf { it.isNotBlank() } ?: return null + val source = getAttribute(ZeebeModelConstants.ATTRIBUTE_SOURCE)?.takeIf { it.isNotBlank() } + return IoMapping.Parameter(target = target, source = source) + } + + private fun ModelElementInstance.propagateFlag(attribute: String): Boolean? { + return getAttributeValue(attribute)?.takeIf { it.isNotBlank() }?.toBooleanStrictOrNull() + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/ZeebeModelConstants.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/ZeebeModelConstants.kt similarity index 89% rename from bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/ZeebeModelConstants.kt rename to bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/ZeebeModelConstants.kt index cd909a8f..74150dbb 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/constants/ZeebeModelConstants.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/dialect/ZeebeModelConstants.kt @@ -1,10 +1,10 @@ -package io.miragon.bpmn.adapter.outbound.engine.constants +package io.miragon.bpmn.adapter.outbound.engine.dialect /** * Mimics [org.camunda.bpm.model.bpmn.impl.BpmnModelConstants] for Zeebe * Provies constants used in BPMN models of zeebe, required to extract the model. */ -object ZeebeModelConstants { +internal object ZeebeModelConstants { const val NAMESPACE = "http://camunda.org/schema/zeebe/1.0" @@ -26,4 +26,5 @@ object ZeebeModelConstants { const val ATTRIBUTE_OUTPUT_ELEMENT = "outputElement" const val ATTRIBUTE_OUTPUT_COLLECTION = "outputCollection" const val ATTRIBUTE_MODELER_TEMPLATE = "modelerTemplate" + const val ATTRIBUTE_RETRIES = "retries" } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ImplementationKind.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ImplementationKind.kt deleted file mode 100644 index 173fa0be..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ImplementationKind.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -enum class Camunda7ImplementationKind { - EXTERNAL_TASK, JAVA_DELEGATE, DELEGATE_EXPRESSION, EXPRESSION -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ModelExtractor.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ModelExtractor.kt deleted file mode 100644 index 0651ac60..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ModelExtractor.kt +++ /dev/null @@ -1,346 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -import io.miragon.bpmn.adapter.outbound.engine.constants.CamundaModelConstants -import io.miragon.bpmn.adapter.outbound.engine.utils.BaseElementUtils.findExtensionElements -import io.miragon.bpmn.adapter.outbound.engine.utils.DomElementUtils.withAttribute -import io.miragon.bpmn.adapter.outbound.engine.utils.DomElementUtils.withElementName -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.extractAttribute -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.filterByType -import io.miragon.bpmn.adapter.outbound.engine.utils.MessageUtils.findAllMessagesWithSource -import io.miragon.bpmn.adapter.outbound.engine.utils.MessageUtils.findMessageEventProperties -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findCompensateEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findErrorEventDefinition -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findEscalationEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findFlowNodes -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findSequenceFlows -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findSignalEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findTimerEventDefinition -import io.miragon.bpmn.adapter.outbound.engine.utils.SignalUtils.findSignalEventProperties -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.extractVariantName -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.getProcessId -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.isExecutable -import io.miragon.bpmn.domain.BpmnModel -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_AFTER_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_BEFORE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.EXCLUSIVE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition -import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.adapter.outbound.engine.EngineDetector -import io.miragon.bpmn.adapter.outbound.engine.SecureBpmnParser -import io.miragon.bpmn.domain.utils.StringUtils.removeExpressionSyntax -import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants -import org.camunda.bpm.model.bpmn.instance.CallActivity -import org.camunda.bpm.model.bpmn.instance.FlowNode -import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition -import org.camunda.bpm.model.bpmn.instance.MultiInstanceLoopCharacteristics -import org.camunda.bpm.model.bpmn.instance.ServiceTask -import org.camunda.bpm.model.xml.ModelInstance -import org.camunda.bpm.model.xml.instance.DomElement -import org.camunda.bpm.model.xml.instance.ModelElementInstance -@Suppress("TooManyFunctions") -class Camunda7ModelExtractor : EngineSpecificExtractor { - - private val implKindKey = ServiceTaskDefinition.IMPL_KIND_KEY - private val implValueKey = ServiceTaskDefinition.IMPL_VALUE_KEY - - override fun extract(bytes: ByteArray): BpmnModel { - val modelInstance = SecureBpmnParser.readModelFromBytes(bytes) - val processId = modelInstance.getProcessId() - val variantName = modelInstance.extractVariantName() - val allMessages = findMessages(modelInstance) - val allFlowNodes = modelInstance.findFlowNodes() - val allSequenceFlows = modelInstance.findSequenceFlows() - val serviceTasks = getServiceTaskTypes(modelInstance) - val callActivities = findCallActivities(modelInstance) - val messageSendEvents = findMessageSendEvents(modelInstance) - val signals = modelInstance.findSignalEventDefinitions() - val errors = modelInstance.findErrorEventDefinition() - val escalations = modelInstance.findEscalationEventDefinitions() - val compensations = modelInstance.findCompensateEventDefinitions() - val timers = modelInstance.findTimerEventDefinition() - val variablesPerNode = extractVariablesPerNode(modelInstance) - - val asyncPerNode = extractAsyncPerNode(modelInstance) - val eventProperties: Map = - modelInstance.findMessageEventProperties() + modelInstance.findSignalEventProperties() - val allServiceTasks = serviceTasks + messageSendEvents - val enrichedFlowNodes = enrichFlowNodes( - flowNodes = allFlowNodes, - serviceTasks = allServiceTasks, - callActivities = callActivities, - timers = timers, - eventProperties = eventProperties, - variablesPerNode = variablesPerNode, - asyncPerNode = asyncPerNode, - ) - - return BpmnModel( - processId = processId, - variantName = variantName, - flowNodes = enrichedFlowNodes, - sequenceFlows = allSequenceFlows, - messages = allMessages, - signals = signals, - errors = errors, - escalations = escalations, - compensations = compensations, - detectedEngine = EngineDetector.detect(bytes.decodeToString()), - isExecutable = modelInstance.isExecutable(), - ) - } - - private fun enrichFlowNodes( - flowNodes: List, - serviceTasks: List, - callActivities: List, - timers: List, - eventProperties: Map, - variablesPerNode: Map>, - asyncPerNode: Map>, - ): List { - val serviceTaskById = serviceTasks.associateBy { it.id } - val callActivityById = callActivities.associateBy { it.id } - val timerById = timers.associateBy { it.id } - val attachedElementsById = flowNodes - .filter { it.attachedToRef != null } - .groupBy { it.attachedToRef!! } - .mapValues { (_, nodes) -> nodes.mapNotNull { it.id } } - return flowNodes.map { node -> - val properties = resolveProperties(node.id, serviceTaskById, callActivityById, timerById, eventProperties) - val variables = variablesPerNode[node.id] ?: emptyList() - val attachedElements = attachedElementsById[node.id] ?: emptyList() - val engineSpecificProperties = asyncPerNode[node.id] ?: emptyMap() - node.copy(properties = properties, variables = variables, attachedElements = attachedElements, engineSpecificProperties = engineSpecificProperties) - } - } - - private fun extractAsyncPerNode(modelInstance: ModelInstance): Map> { - val flowNodes = modelInstance.getModelElementsByType(FlowNode::class.java) - return flowNodes.associate { node -> - val nodeId = node.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - // isCamundaAsync* throws for FlowNode subtypes that don't carry the Camunda extension attribute - val asyncBefore = runCatching { node.isCamundaAsyncBefore }.getOrDefault(false) - val asyncAfter = runCatching { node.isCamundaAsyncAfter }.getOrDefault(false) - val exclusive = runCatching { node.isCamundaExclusive }.getOrDefault(true) - val props = buildMap { - if (asyncBefore) put(ASYNC_BEFORE_KEY, true) - if (asyncAfter) put(ASYNC_AFTER_KEY, true) - if (!exclusive) put(EXCLUSIVE_KEY, false) - } - nodeId to props - } - } - - private fun resolveProperties( - nodeId: String?, - serviceTasks: Map, - callActivities: Map, - timers: Map, - eventProperties: Map, - ): FlowNodeProperties { - serviceTasks[nodeId]?.let { return FlowNodeProperties.ServiceTask(it) } - callActivities[nodeId]?.let { return FlowNodeProperties.CallActivity(it) } - timers[nodeId]?.let { return FlowNodeProperties.Timer(it) } - return nodeId?.let { eventProperties[it] } ?: FlowNodeProperties.None - } - - private fun findMessages(modelInstance: ModelInstance): List { - return modelInstance.findAllMessagesWithSource().map { (elementId, name, _) -> - MessageDefinition(id = elementId, name = name) - } - } - - private fun findCallActivities(modelInstance: ModelInstance): List { - val callActivities = modelInstance.getModelElementsByType(CallActivity::class.java) - return callActivities.map { activity -> - val id = activity.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val calledElement = activity.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_CALLED_ELEMENT) - val extensions = activity.findExtensionElements() - val propagateAllInput = extractPropagateAll(extensions, BpmnModelConstants.CAMUNDA_ELEMENT_IN) - val propagateAllOutput = extractPropagateAll(extensions, BpmnModelConstants.CAMUNDA_ELEMENT_OUT) - CallActivityDefinition( - id = id, - calledElement = calledElement, - mappings = extractCallActivityMappings(extensions), - engineSpecificProperties = buildMap { - propagateAllInput?.let { put(CallActivityDefinition.PROPAGATE_ALL_INPUT_KEY, it) } - propagateAllOutput?.let { put(CallActivityDefinition.PROPAGATE_ALL_OUTPUT_KEY, it) } - }, - ) - } - } - - private fun extractCallActivityMappings( - extensions: List - ): List { - val rawInputs = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_IN) - val inputs = rawInputs.mapNotNull { it.domElement.toCallActivityMapping(VariableDirection.INPUT) } - val rawOutputs = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_OUT) - val outputs = rawOutputs.mapNotNull { it.domElement.toCallActivityMapping(VariableDirection.OUTPUT) } - return inputs + outputs - } - - private fun DomElement.toCallActivityMapping(direction: VariableDirection): CallActivityMapping? { - val source = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE)?.takeIf { it.isNotBlank() } - val sourceExpression = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE_EXPRESSION)?.takeIf { it.isNotBlank() } - val target = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_TARGET)?.takeIf { it.isNotBlank() } - if (source == null && sourceExpression == null && target == null) return null - return CallActivityMapping(direction, source, sourceExpression, target) - } - - private fun extractPropagateAll( - extensions: List, - elementType: String, - ): Boolean? { - val hasAll = extensions.filterByType(elementType).any { - it.domElement.getAttribute(CamundaModelConstants.VARIABLES_ATTRIBUTE) == CamundaModelConstants.VARIABLES_ALL_VALUE - } - return if (hasAll) true else null - } - - private fun getServiceTaskTypes(modelInstance: ModelInstance): List { - val serviceTasks = modelInstance.getModelElementsByType(ServiceTask::class.java) - return serviceTasks.map { it.toServiceTask() } - } - - private fun ServiceTask.toServiceTask(): ServiceTaskDefinition { - val taskId = this.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val (kind, implValue) = this.detectImplementation() - return ServiceTaskDefinition( - id = taskId, - engineSpecificProperties = buildMap { - put(implValueKey, implValue) - put(implKindKey, kind) - } - ) - } - - private fun ServiceTask.detectImplementation(): Pair = when { - this.camundaTopic != null -> Camunda7ImplementationKind.EXTERNAL_TASK.name to this.camundaTopic - this.camundaDelegateExpression != null -> Camunda7ImplementationKind.DELEGATE_EXPRESSION.name to this.camundaDelegateExpression - this.camundaClass != null -> Camunda7ImplementationKind.JAVA_DELEGATE.name to this.camundaClass - this.camundaExpression != null -> Camunda7ImplementationKind.EXPRESSION.name to this.camundaExpression - else -> null to null - } - - private fun findMessageSendEvents(modelInstance: ModelInstance): List { - val messageEvents = modelInstance.getModelElementsByType(MessageEventDefinition::class.java) - return messageEvents.mapNotNull { event -> - val (kind, implValue) = event.detectImplementation() - if (implValue == null) return@mapNotNull null - val taskId = event.parentElement?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - ServiceTaskDefinition( - id = taskId, - engineSpecificProperties = buildMap { - put(implValueKey, implValue) - put(implKindKey, kind) - } - ) - } - } - - private fun MessageEventDefinition.detectImplementation(): Pair = when { - this.camundaTopic != null -> Camunda7ImplementationKind.EXTERNAL_TASK.name to this.camundaTopic - this.camundaDelegateExpression != null -> Camunda7ImplementationKind.DELEGATE_EXPRESSION.name to this.camundaDelegateExpression - this.camundaClass != null -> Camunda7ImplementationKind.JAVA_DELEGATE.name to this.camundaClass - this.camundaExpression != null -> Camunda7ImplementationKind.EXPRESSION.name to this.camundaExpression - else -> null to null - } - - private fun extractVariablesPerNode(modelInstance: ModelInstance): Map> { - val flowNodes = modelInstance.getModelElementsByType(FlowNode::class.java) - return flowNodes.associate { node -> - val nodeId = node.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val extensions = node.findExtensionElements() - val ioExtensions = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_INPUT_OUTPUT) - val ioVars = extractInputAndOutputVariables(ioExtensions) - val multiInstanceVars = extractMultiInstanceVariables(listOf(node)) - val callActivityMappingVars = extractCallActivityMappingVariables(extensions) - val propertiesExtensions = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_PROPERTIES) - val additionalVars = extractAdditionalVariables(propertiesExtensions) - val allVars = (ioVars + multiInstanceVars + callActivityMappingVars + additionalVars) - .map { (name, direction, expression) -> Triple(name.removeExpressionSyntax(), direction, expression) } - val distinctVars = allVars.distinct().map { (name, direction, expression) -> VariableDefinition(name, direction, expression) } - nodeId to distinctVars - } - } - - private fun extractInputAndOutputVariables( - extensions: List - ): List> { - val allChildElements = extensions.flatMap { it.domElement.childElements } - val inputs = allChildElements - .withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_INPUT_PARAMETER) - .mapNotNull { it.toVariableMapping(VariableDirection.INPUT) } - val outputs = allChildElements - .withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_OUTPUT_PARAMETER) - .mapNotNull { it.toVariableMapping(VariableDirection.OUTPUT) } - return inputs + outputs - } - - /** - * Maps a camunda:inputParameter / camunda:outputParameter element to a variable mapping. - * The variable name comes from the 'name' attribute, while the value expression (right-hand side) - * is the element's raw text content, e.g. `${execution.getVariable('x')}`. - */ - private fun DomElement.toVariableMapping( - direction: VariableDirection - ): Triple? { - val name = this.getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_NAME)?.takeIf { it.isNotBlank() } - ?: return null - val expression = this.textContent?.trim()?.takeIf { it.isNotBlank() } - return Triple(name, direction, expression) - } - - private fun extractAdditionalVariables( - extensions: List - ): List> { - val allChildElements = extensions.flatMap { it.domElement.childElements } - val propertyElements = allChildElements.withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_PROPERTY) - val inputs = readAdditionalVariableValues(propertyElements, CamundaModelConstants.ADDITIONAL_INPUT_VARIABLES_PROPERTY_NAME) - .map { Triple(it, VariableDirection.INPUT, null) } - val outputs = readAdditionalVariableValues(propertyElements, CamundaModelConstants.ADDITIONAL_OUTPUT_VARIABLES_PROPERTY_NAME) - .map { Triple(it, VariableDirection.OUTPUT, null) } - return inputs + outputs - } - - private fun readAdditionalVariableValues( - propertyElements: List, - propertyName: String, - ): List { - val filter = BpmnModelConstants.CAMUNDA_ATTRIBUTE_NAME to propertyName - val matchingProperties = propertyElements.withAttribute(filter) - val rawValues = matchingProperties.map { it.getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_VALUE) } - return rawValues.flatMap { it?.split(",") ?: emptyList() }.map { it.trim() }.filter { it.isNotBlank() } - } - - private fun extractCallActivityMappingVariables( - extensions: List - ): List> { - val inElements = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_IN) - val outElements = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_OUT) - val sourceVars = inElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE) - val sourceExprVars = inElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE_EXPRESSION) - val targetVars = outElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_TARGET) - val inputs = (sourceVars + sourceExprVars).map { Triple(it, VariableDirection.INPUT, it) } - val outputs = targetVars.map { Triple(it, VariableDirection.OUTPUT, it) } - return inputs + outputs - } - - private fun extractMultiInstanceVariables( - nodes: Collection - ): List> { - val loops = nodes.flatMap { it.getChildElementsByType(MultiInstanceLoopCharacteristics::class.java) } - val collectionVariables = loops.mapNotNull { it.camundaCollection }.map { Triple(it, VariableDirection.INPUT, it) } - val elementVariables = loops.mapNotNull { it.camundaElementVariable }.map { Triple(it, VariableDirection.INPUT, it) } - return collectionVariables + elementVariables - } - -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/EngineSpecificExtractor.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/EngineSpecificExtractor.kt deleted file mode 100644 index 0c48393a..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/EngineSpecificExtractor.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -import io.miragon.bpmn.domain.BpmnModel - -fun interface EngineSpecificExtractor { - - /** - * Extracts the BPMN model from the given byte array. - * @param bytes the raw BPMN file content to extract the model from - */ - fun extract(bytes: ByteArray): BpmnModel -} \ No newline at end of file diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonImplementationKind.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonImplementationKind.kt deleted file mode 100644 index fc05a284..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonImplementationKind.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -enum class OperatonImplementationKind { - EXTERNAL_TASK, JAVA_DELEGATE, DELEGATE_EXPRESSION, EXPRESSION -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonModelExtractor.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonModelExtractor.kt deleted file mode 100644 index b87d0f65..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonModelExtractor.kt +++ /dev/null @@ -1,373 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -import io.miragon.bpmn.adapter.outbound.engine.constants.CamundaModelConstants -import io.miragon.bpmn.adapter.outbound.engine.utils.BaseElementUtils.findExtensionElements -import io.miragon.bpmn.adapter.outbound.engine.utils.DomElementUtils.withAttribute -import io.miragon.bpmn.adapter.outbound.engine.utils.DomElementUtils.withElementName -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.extractAttribute -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.filterByType -import io.miragon.bpmn.adapter.outbound.engine.utils.MessageUtils.findAllMessagesWithSource -import io.miragon.bpmn.adapter.outbound.engine.utils.MessageUtils.findMessageEventProperties -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findCompensateEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findErrorEventDefinition -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findEscalationEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findFlowNodes -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findSequenceFlows -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findSignalEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.SignalUtils.findSignalEventProperties -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findTimerEventDefinition -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.extractVariantName -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.getProcessId -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.isExecutable -import io.miragon.bpmn.domain.BpmnModel -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_AFTER_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_BEFORE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.EXCLUSIVE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition -import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.adapter.outbound.engine.EngineDetector -import io.miragon.bpmn.adapter.outbound.engine.SecureBpmnParser -import io.miragon.bpmn.domain.utils.StringUtils.removeExpressionSyntax -import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants -import org.camunda.bpm.model.bpmn.instance.CallActivity -import org.camunda.bpm.model.bpmn.instance.FlowNode -import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition -import org.camunda.bpm.model.bpmn.instance.MultiInstanceLoopCharacteristics -import org.camunda.bpm.model.bpmn.instance.ServiceTask -import org.camunda.bpm.model.xml.ModelInstance -import org.camunda.bpm.model.xml.instance.DomElement -import org.camunda.bpm.model.xml.instance.ModelElementInstance -@Suppress("TooManyFunctions") -class OperatonModelExtractor : EngineSpecificExtractor { - - private val implKindKey = ServiceTaskDefinition.IMPL_KIND_KEY - private val implValueKey = ServiceTaskDefinition.IMPL_VALUE_KEY - - companion object { - private const val NAMESPACE = "http://operaton.org/schema/1.0/bpmn" - } - - override fun extract(bytes: ByteArray): BpmnModel { - val modelInstance = SecureBpmnParser.readModelFromBytes(bytes) - val processId = modelInstance.getProcessId() - val variantName = modelInstance.extractVariantName() - val messages = findMessages(modelInstance) - val flowNodes = modelInstance.findFlowNodes() - val allSequenceFlows = modelInstance.findSequenceFlows() - val serviceTasks = getServiceTaskTypes(modelInstance) - val callActivities = findCallActivities(modelInstance) - val messageSendEvents = findMessageSendEvents(modelInstance) - val signals = modelInstance.findSignalEventDefinitions() - val errors = modelInstance.findErrorEventDefinition() - val escalations = modelInstance.findEscalationEventDefinitions() - val compensations = modelInstance.findCompensateEventDefinitions() - val timers = modelInstance.findTimerEventDefinition() - val variablesPerNode = extractVariablesPerNode(modelInstance) - - val asyncPerNode = extractAsyncPerNode(modelInstance) - val eventProperties: Map = - modelInstance.findMessageEventProperties() + modelInstance.findSignalEventProperties() - val allServiceTasks = serviceTasks + messageSendEvents - val enrichedFlowNodes = enrichFlowNodes( - flowNodes = flowNodes, - serviceTasks = allServiceTasks, - callActivities = callActivities, - timers = timers, - eventProperties = eventProperties, - variablesPerNode = variablesPerNode, - asyncPerNode = asyncPerNode, - ) - - return BpmnModel( - processId = processId, - variantName = variantName, - flowNodes = enrichedFlowNodes, - sequenceFlows = allSequenceFlows, - messages = messages, - signals = signals, - errors = errors, - escalations = escalations, - compensations = compensations, - detectedEngine = EngineDetector.detect(bytes.decodeToString()), - isExecutable = modelInstance.isExecutable(), - ) - } - - private fun enrichFlowNodes( - flowNodes: List, - serviceTasks: List, - callActivities: List, - timers: List, - eventProperties: Map, - variablesPerNode: Map>, - asyncPerNode: Map>, - ): List { - val serviceTaskById = serviceTasks.associateBy { it.id } - val callActivityById = callActivities.associateBy { it.id } - val timerById = timers.associateBy { it.id } - val attachedElementsById = flowNodes - .filter { it.attachedToRef != null } - .groupBy { it.attachedToRef!! } - .mapValues { (_, nodes) -> nodes.mapNotNull { it.id } } - return flowNodes.map { node -> - val properties = resolveProperties(node.id, serviceTaskById, callActivityById, timerById, eventProperties) - val variables = variablesPerNode[node.id] ?: emptyList() - val attachedElements = attachedElementsById[node.id] ?: emptyList() - val engineSpecificProperties = asyncPerNode[node.id] ?: emptyMap() - node.copy(properties = properties, variables = variables, attachedElements = attachedElements, engineSpecificProperties = engineSpecificProperties) - } - } - - private fun extractAsyncPerNode(modelInstance: ModelInstance): Map> { - val flowNodes = modelInstance.getModelElementsByType(FlowNode::class.java) - return flowNodes.associate { node -> - val nodeId = node.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val asyncBefore = node.getAttributeValueNs(NAMESPACE, "asyncBefore")?.toBoolean() ?: false - val asyncAfter = node.getAttributeValueNs(NAMESPACE, "asyncAfter")?.toBoolean() ?: false - val exclusive = node.getAttributeValueNs(NAMESPACE, "exclusive")?.toBoolean() - val props = buildMap { - if (asyncBefore) put(ASYNC_BEFORE_KEY, true) - if (asyncAfter) put(ASYNC_AFTER_KEY, true) - if (exclusive == false) put(EXCLUSIVE_KEY, false) - } - nodeId to props - } - } - - private fun resolveProperties( - nodeId: String?, - serviceTasks: Map, - callActivities: Map, - timers: Map, - eventProperties: Map, - ): FlowNodeProperties { - serviceTasks[nodeId]?.let { return FlowNodeProperties.ServiceTask(it) } - callActivities[nodeId]?.let { return FlowNodeProperties.CallActivity(it) } - timers[nodeId]?.let { return FlowNodeProperties.Timer(it) } - return nodeId?.let { eventProperties[it] } ?: FlowNodeProperties.None - } - - private fun findMessages(modelInstance: ModelInstance): List { - return modelInstance.findAllMessagesWithSource().map { (elementId, name, _) -> - MessageDefinition(id = elementId, name = name) - } - } - - private fun findCallActivities(modelInstance: ModelInstance): List { - val callActivities = modelInstance.getModelElementsByType(CallActivity::class.java) - return callActivities.map { activity -> - val id = activity.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val calledElement = activity.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_CALLED_ELEMENT) - val extensions = activity.findExtensionElements() - val propagateAllInput = extractPropagateAll(extensions, BpmnModelConstants.CAMUNDA_ELEMENT_IN) - val propagateAllOutput = extractPropagateAll(extensions, BpmnModelConstants.CAMUNDA_ELEMENT_OUT) - CallActivityDefinition( - id = id, - calledElement = calledElement, - mappings = extractCallActivityMappings(extensions), - engineSpecificProperties = buildMap { - propagateAllInput?.let { put(CallActivityDefinition.PROPAGATE_ALL_INPUT_KEY, it) } - propagateAllOutput?.let { put(CallActivityDefinition.PROPAGATE_ALL_OUTPUT_KEY, it) } - }, - ) - } - } - - private fun extractCallActivityMappings( - extensions: List - ): List { - val rawInputs = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_IN) - val inputs = rawInputs.mapNotNull { it.domElement.toCallActivityMapping(VariableDirection.INPUT) } - val rawOutputs = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_OUT) - val outputs = rawOutputs.mapNotNull { it.domElement.toCallActivityMapping(VariableDirection.OUTPUT) } - return inputs + outputs - } - - private fun DomElement.toCallActivityMapping(direction: VariableDirection): CallActivityMapping? { - val source = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE)?.takeIf { it.isNotBlank() } - val sourceExpression = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE_EXPRESSION)?.takeIf { it.isNotBlank() } - val target = getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_TARGET)?.takeIf { it.isNotBlank() } - if (source == null && sourceExpression == null && target == null) return null - return CallActivityMapping(direction, source, sourceExpression, target) - } - - private fun extractPropagateAll( - extensions: List, - elementType: String, - ): Boolean? { - val hasAll = extensions.filterByType(elementType).any { - it.domElement.getAttribute(CamundaModelConstants.VARIABLES_ATTRIBUTE) == CamundaModelConstants.VARIABLES_ALL_VALUE - } - return if (hasAll) true else null - } - - private fun getServiceTaskTypes(modelInstance: ModelInstance): List { - val serviceTasks = modelInstance.getModelElementsByType(ServiceTask::class.java) - return serviceTasks.map { it.toServiceTask() } - } - - private fun ServiceTask.toServiceTask(): ServiceTaskDefinition { - val taskId = this.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val (kind, implValue) = this.detectImplementation() - return ServiceTaskDefinition( - id = taskId, - engineSpecificProperties = buildMap { - put(implValueKey, implValue) - put(implKindKey, kind) - } - ) - } - - private fun ServiceTask.detectImplementation(): Pair { - val extractor = { attrName: String -> this.getAttributeValueNs(NAMESPACE, attrName) } - val delegateExpression = extractor(BpmnModelConstants.CAMUNDA_ATTRIBUTE_DELEGATE_EXPRESSION) - val javaClass = extractor(BpmnModelConstants.CAMUNDA_ATTRIBUTE_CLASS) - val topic = extractor(BpmnModelConstants.CAMUNDA_ATTRIBUTE_TOPIC) - val expression = extractor(BpmnModelConstants.CAMUNDA_ATTRIBUTE_EXPRESSION) - return when { - delegateExpression != null -> OperatonImplementationKind.DELEGATE_EXPRESSION.name to delegateExpression - javaClass != null -> OperatonImplementationKind.JAVA_DELEGATE.name to javaClass - topic != null -> OperatonImplementationKind.EXTERNAL_TASK.name to topic - expression != null -> OperatonImplementationKind.EXPRESSION.name to expression - else -> null to null - } - } - - private fun findMessageSendEvents(modelInstance: ModelInstance): List { - val messageEvents = modelInstance.getModelElementsByType(MessageEventDefinition::class.java) - return messageEvents.mapNotNull { event -> - val (kind, implValue) = event.detectImplementation() - if (implValue == null) return@mapNotNull null - val taskId = event.parentElement?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - ServiceTaskDefinition( - id = taskId, - engineSpecificProperties = buildMap { - put(implValueKey, implValue) - put(implKindKey, kind) - } - ) - } - } - - private fun MessageEventDefinition.detectImplementation(): Pair { - val extractor = { attrName: String -> this.getAttributeValueNs(NAMESPACE, attrName) } - val topic = extractor(BpmnModelConstants.CAMUNDA_ATTRIBUTE_TOPIC) - val delegateExpression = extractor(BpmnModelConstants.CAMUNDA_ATTRIBUTE_DELEGATE_EXPRESSION) - val javaClass = extractor(BpmnModelConstants.CAMUNDA_ATTRIBUTE_CLASS) - val expression = extractor(BpmnModelConstants.CAMUNDA_ATTRIBUTE_EXPRESSION) - return when { - topic != null -> OperatonImplementationKind.EXTERNAL_TASK.name to topic - delegateExpression != null -> OperatonImplementationKind.DELEGATE_EXPRESSION.name to delegateExpression - javaClass != null -> OperatonImplementationKind.JAVA_DELEGATE.name to javaClass - expression != null -> OperatonImplementationKind.EXPRESSION.name to expression - else -> null to null - } - } - - private fun extractVariablesPerNode(modelInstance: ModelInstance): Map> { - val flowNodes = modelInstance.getModelElementsByType(FlowNode::class.java) - return flowNodes.associate { node -> - val nodeId = node.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val extensions = node.findExtensionElements() - val ioExtensions = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_INPUT_OUTPUT) - val ioVars = extractInputAndOutputVariables(ioExtensions) - val multiInstanceVars = extractMultiInstanceVariables(listOf(node)) - val callActivityMappingVars = extractCallActivityMappingVariables(extensions) - val propertiesExtensions = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_PROPERTIES) - val additionalVars = extractAdditionalVariables(propertiesExtensions) - val allVars = (ioVars + multiInstanceVars + callActivityMappingVars + additionalVars) - .map { (name, direction, expression) -> Triple(name.removeExpressionSyntax(), direction, expression) } - val distinctVars = allVars.distinct().map { (name, direction, expression) -> VariableDefinition(name, direction, expression) } - nodeId to distinctVars - } - } - - private fun extractInputAndOutputVariables( - extensions: List - ): List> { - val allChildElements = extensions.flatMap { it.domElement.childElements } - val inputs = allChildElements - .withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_INPUT_PARAMETER) - .mapNotNull { it.toVariableMapping(VariableDirection.INPUT) } - val outputs = allChildElements - .withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_OUTPUT_PARAMETER) - .mapNotNull { it.toVariableMapping(VariableDirection.OUTPUT) } - return inputs + outputs - } - - /** - * Maps an operaton:inputParameter / operaton:outputParameter element to a variable mapping. - * The variable name comes from the 'name' attribute, while the value expression (right-hand side) - * is the element's raw text content, e.g. `${execution.getVariable('x')}`. - */ - private fun DomElement.toVariableMapping( - direction: VariableDirection - ): Triple? { - val name = this.getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_NAME)?.takeIf { it.isNotBlank() } - ?: return null - val expression = this.textContent?.trim()?.takeIf { it.isNotBlank() } - return Triple(name, direction, expression) - } - - private fun extractAdditionalVariables( - extensions: List - ): List> { - val allChildElements = extensions.flatMap { it.domElement.childElements } - val propertyElements = allChildElements.withElementName(BpmnModelConstants.CAMUNDA_ELEMENT_PROPERTY) - val inputs = readAdditionalVariableValues(propertyElements, CamundaModelConstants.ADDITIONAL_INPUT_VARIABLES_PROPERTY_NAME) - .map { Triple(it, VariableDirection.INPUT, null) } - val outputs = readAdditionalVariableValues(propertyElements, CamundaModelConstants.ADDITIONAL_OUTPUT_VARIABLES_PROPERTY_NAME) - .map { Triple(it, VariableDirection.OUTPUT, null) } - return inputs + outputs - } - - private fun readAdditionalVariableValues( - propertyElements: List, - propertyName: String, - ): List { - val filter = BpmnModelConstants.CAMUNDA_ATTRIBUTE_NAME to propertyName - val matchingProperties = propertyElements.withAttribute(filter) - val rawValues = matchingProperties.map { it.getAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_VALUE) } - return rawValues.flatMap { it?.split(",") ?: emptyList() }.map { it.trim() }.filter { it.isNotBlank() } - } - - private fun extractCallActivityMappingVariables( - extensions: List - ): List> { - val inElements = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_IN) - val outElements = extensions.filterByType(BpmnModelConstants.CAMUNDA_ELEMENT_OUT) - val sourceVars = inElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE) - val sourceExprVars = inElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_SOURCE_EXPRESSION) - val targetVars = outElements.extractAttribute(BpmnModelConstants.CAMUNDA_ATTRIBUTE_TARGET) - val inputs = (sourceVars + sourceExprVars).map { Triple(it, VariableDirection.INPUT, it) } - val outputs = targetVars.map { Triple(it, VariableDirection.OUTPUT, it) } - return inputs + outputs - } - - private fun extractMultiInstanceVariables( - nodes: Collection - ): List> { - val loops = nodes.flatMap { it.getChildElementsByType(MultiInstanceLoopCharacteristics::class.java) } - val collectionVariables = loops - .extractVariablesFromLoops(BpmnModelConstants.CAMUNDA_ATTRIBUTE_COLLECTION) - .map { Triple(it, VariableDirection.INPUT, it) } - val elementVariables = loops - .extractVariablesFromLoops(BpmnModelConstants.CAMUNDA_ATTRIBUTE_ELEMENT_VARIABLE) - .map { Triple(it, VariableDirection.INPUT, it) } - return collectionVariables + elementVariables - } - - private fun List.extractVariablesFromLoops( - variableType: String - ): List { - return this.mapNotNull { it.getAttributeValueNs(NAMESPACE, variableType) } - } - -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeImplementationKind.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeImplementationKind.kt deleted file mode 100644 index e47a5c3c..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeImplementationKind.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -enum class ZeebeImplementationKind { - JOB_WORKER, - CONNECTOR, -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeModelExtractor.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeModelExtractor.kt deleted file mode 100644 index 7a372004..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeModelExtractor.kt +++ /dev/null @@ -1,248 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -import io.miragon.bpmn.adapter.outbound.engine.constants.ZeebeModelConstants -import io.miragon.bpmn.adapter.outbound.engine.utils.BaseElementUtils.findExtensionElement -import io.miragon.bpmn.adapter.outbound.engine.utils.BaseElementUtils.findExtensionElements -import io.miragon.bpmn.adapter.outbound.engine.utils.BaseElementUtils.findExtensionElementsWithType -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.extractAttribute -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.filterByType -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.findFirstByType -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.nonBlankAttribute -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelElementInstanceUtils.nonBlankAttributeNs -import io.miragon.bpmn.adapter.outbound.engine.utils.MessageUtils.findAllMessagesWithSource -import io.miragon.bpmn.adapter.outbound.engine.utils.MessageUtils.findMessageEventProperties -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findCompensateEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findErrorEventDefinition -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findEscalationEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findFlowNodes -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findSequenceFlows -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findSignalEventDefinitions -import io.miragon.bpmn.adapter.outbound.engine.utils.SignalUtils.findSignalEventProperties -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.findTimerEventDefinition -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.extractVariantName -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.getProcessId -import io.miragon.bpmn.adapter.outbound.engine.utils.ModelInstanceUtils.isExecutable -import io.miragon.bpmn.domain.BpmnModel -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition -import io.miragon.bpmn.adapter.outbound.engine.EngineDetector -import io.miragon.bpmn.adapter.outbound.engine.SecureBpmnParser -import io.miragon.bpmn.domain.shared.VariableDirection -import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants -import org.camunda.bpm.model.bpmn.instance.CallActivity -import org.camunda.bpm.model.bpmn.instance.FlowNode -import org.camunda.bpm.model.bpmn.instance.Message -import org.camunda.bpm.model.bpmn.instance.MultiInstanceLoopCharacteristics -import org.camunda.bpm.model.xml.ModelInstance -import org.camunda.bpm.model.xml.instance.DomElement -import org.camunda.bpm.model.xml.instance.ModelElementInstance -class ZeebeModelExtractor : EngineSpecificExtractor { - - private val implKindKey = ServiceTaskDefinition.IMPL_KIND_KEY - private val implValueKey = ServiceTaskDefinition.IMPL_VALUE_KEY - - override fun extract(bytes: ByteArray): BpmnModel { - val modelInstance = SecureBpmnParser.readModelFromBytes(bytes) - val processId = modelInstance.getProcessId() - val variantName = modelInstance.extractVariantName() - val allFlowNodes = modelInstance.findFlowNodes() - val allSequenceFlows = modelInstance.findSequenceFlows() - val allMessages = extractZeebeMessages(modelInstance) - val allErrorEvents = modelInstance.findErrorEventDefinition() - val allEscalationEvents = modelInstance.findEscalationEventDefinitions() - val allCompensationEvents = modelInstance.findCompensateEventDefinitions() - val allTimerEvents = modelInstance.findTimerEventDefinition() - val allSignalEvents = modelInstance.findSignalEventDefinitions() - val allServiceTasks = findServiceTasks(modelInstance) - val allCallActivities = findCallActivities(modelInstance) - val variablesPerNode = extractVariablesPerNode(modelInstance) - val eventProperties: Map = - modelInstance.findMessageEventProperties() + modelInstance.findSignalEventProperties() - - val enrichedFlowNodes = enrichFlowNodes( - flowNodes = allFlowNodes, - serviceTasks = allServiceTasks, - callActivities = allCallActivities, - timers = allTimerEvents, - eventProperties = eventProperties, - variablesPerNode = variablesPerNode, - ) - - return BpmnModel( - processId = processId, - variantName = variantName, - flowNodes = enrichedFlowNodes, - sequenceFlows = allSequenceFlows, - messages = allMessages, - signals = allSignalEvents, - errors = allErrorEvents, - escalations = allEscalationEvents, - compensations = allCompensationEvents, - detectedEngine = EngineDetector.detect(bytes.decodeToString()), - isExecutable = modelInstance.isExecutable(), - ) - } - - private fun enrichFlowNodes( - flowNodes: List, - serviceTasks: List, - callActivities: List, - timers: List, - eventProperties: Map, - variablesPerNode: Map>, - ): List { - val serviceTaskById = serviceTasks.associateBy { it.id } - val callActivityById = callActivities.associateBy { it.id } - val timerById = timers.associateBy { it.id } - val attachedElementsById = flowNodes - .filter { it.attachedToRef != null } - .groupBy { it.attachedToRef!! } - .mapValues { (_, nodes) -> nodes.mapNotNull { it.id } } - return flowNodes.map { node -> - val properties = resolveProperties(node.id, serviceTaskById, callActivityById, timerById, eventProperties) - val variables = variablesPerNode[node.id] ?: emptyList() - val attachedElements = attachedElementsById[node.id] ?: emptyList() - node.copy(properties = properties, variables = variables, attachedElements = attachedElements) - } - } - - private fun resolveProperties( - nodeId: String?, - serviceTasks: Map, - callActivities: Map, - timers: Map, - eventProperties: Map, - ): FlowNodeProperties { - serviceTasks[nodeId]?.let { return FlowNodeProperties.ServiceTask(it) } - callActivities[nodeId]?.let { return FlowNodeProperties.CallActivity(it) } - timers[nodeId]?.let { return FlowNodeProperties.Timer(it) } - return nodeId?.let { eventProperties[it] } ?: FlowNodeProperties.None - } - - private fun findCallActivities(modelInstance: ModelInstance): List { - val callActivities = modelInstance.getModelElementsByType(CallActivity::class.java) - return callActivities.map { activity -> - val elementId = activity.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val calledElement = activity.findExtensionElement(BpmnModelConstants.BPMN_ATTRIBUTE_CALLED_ELEMENT) - val processId = calledElement?.getAttributeValue(ZeebeModelConstants.ATTRIBUTE_PROCESS_ID) - val ioMappings = activity.findExtensionElementsWithType(ZeebeModelConstants.ELEMENT_IO_MAPPING) - val propagateAllInput = calledElement?.propagateFlag(ZeebeModelConstants.ATTRIBUTE_PROPAGATE_PARENT) - val propagateAllOutput = calledElement?.propagateFlag(ZeebeModelConstants.ATTRIBUTE_PROPAGATE_CHILD) - CallActivityDefinition( - id = elementId, - calledElement = processId, - mappings = extractCallActivityMappings(ioMappings), - engineSpecificProperties = buildMap { - propagateAllInput?.let { put(CallActivityDefinition.PROPAGATE_ALL_INPUT_KEY, it) } - propagateAllOutput?.let { put(CallActivityDefinition.PROPAGATE_ALL_OUTPUT_KEY, it) } - }, - ) - } - } - - private fun ModelElementInstance.propagateFlag(attribute: String): Boolean? { - return getAttributeValue(attribute)?.takeIf { it.isNotBlank() }?.toBooleanStrictOrNull() - } - - private fun extractCallActivityMappings(ioMappings: List): List { - val elements = ioMappings.flatMap { it.domElement.childElements } - val rawInputs = elements.filter { it.localName == ZeebeModelConstants.ELEMENT_INPUT } - val inputs = rawInputs.mapNotNull { it.toCallActivityMapping(VariableDirection.INPUT) } - val rawOutputs = elements.filter { it.localName == ZeebeModelConstants.ELEMENT_OUTPUT } - val outputs = rawOutputs.mapNotNull { it.toCallActivityMapping(VariableDirection.OUTPUT) } - return inputs + outputs - } - - private fun DomElement.toCallActivityMapping(direction: VariableDirection): CallActivityMapping? { - val target = getAttribute(ZeebeModelConstants.ATTRIBUTE_TARGET)?.takeIf { it.isNotBlank() } ?: return null - val source = getAttribute(ZeebeModelConstants.ATTRIBUTE_SOURCE)?.takeIf { it.isNotBlank() } - return CallActivityMapping(direction, source = source, sourceExpression = null, target = target) - } - - private fun findServiceTasks(modelInstance: ModelInstance): List { - val flowNodes = modelInstance.getModelElementsByType(FlowNode::class.java) - return flowNodes.mapNotNull { node -> - val extensionElements = node.findExtensionElements() - val taskDefinition = extensionElements.findFirstByType(ZeebeModelConstants.ELEMENT_TASK_DEFINITION) - ?: return@mapNotNull null - val id = node.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val type = taskDefinition.nonBlankAttribute(BpmnModelConstants.BPMN_ATTRIBUTE_TYPE) - val modelerTemplate = node.nonBlankAttributeNs(ZeebeModelConstants.NAMESPACE, ZeebeModelConstants.ATTRIBUTE_MODELER_TEMPLATE) - ServiceTaskDefinition( - id = id, - engineSpecificProperties = buildMap { - put(implValueKey, type) - put(implKindKey, when { - modelerTemplate != null -> ZeebeImplementationKind.CONNECTOR.name - else -> ZeebeImplementationKind.JOB_WORKER.name - }) - } - ) - } - } - - private fun extractZeebeMessages(modelInstance: ModelInstance): List { - return modelInstance.findAllMessagesWithSource().map { (elementId, name, message) -> - val engineSpecificProperties = message?.zeebeSubscriptionProperties() ?: emptyMap() - MessageDefinition(id = elementId, name = name, engineSpecificProperties = engineSpecificProperties) - } - } - - private fun Message.zeebeSubscriptionProperties(): Map { - val subscription = this.findExtensionElementsWithType(ZeebeModelConstants.ELEMENT_SUBSCRIPTION).firstOrNull() - ?: return emptyMap() - val correlationKey = subscription.getAttributeValue(ZeebeModelConstants.ATTRIBUTE_CORRELATION_KEY) - ?: return emptyMap() - return mapOf(ZeebeModelConstants.ATTRIBUTE_CORRELATION_KEY to correlationKey) - } - - private fun extractVariablesPerNode(modelInstance: ModelInstance): Map> { - val flowNodes = modelInstance.getModelElementsByType(FlowNode::class.java) - return flowNodes.associate { node -> - val nodeId = node.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val ioMappings = node.findExtensionElementsWithType(ZeebeModelConstants.ELEMENT_IO_MAPPING) - val inputs = extractIoVariables(ioMappings, ZeebeModelConstants.ELEMENT_INPUT, VariableDirection.INPUT) - val outputs = extractIoVariables(ioMappings, ZeebeModelConstants.ELEMENT_OUTPUT, VariableDirection.OUTPUT) - val multiInstanceVars = extractMultiInstanceVariables(listOf(node)) - val allVars = inputs + outputs + multiInstanceVars - val distinctVars = allVars.distinct().map { (name, direction, expression) -> VariableDefinition(name, direction, expression) } - nodeId to distinctVars - } - } - - private fun extractIoVariables( - extensions: List, - elementName: String, - direction: VariableDirection, - ): List> { - val allElementsInContainer = extensions.flatMap { it.domElement.childElements } - val matching = allElementsInContainer.filter { it.localName == elementName } - return matching.mapNotNull { element -> - val target = element.getAttribute(ZeebeModelConstants.ATTRIBUTE_TARGET)?.takeIf { it.isNotBlank() } - ?: return@mapNotNull null - val source = element.getAttribute(ZeebeModelConstants.ATTRIBUTE_SOURCE)?.takeIf { it.isNotBlank() } - Triple(target, direction, source) - } - } - - private fun extractMultiInstanceVariables( - nodes: Collection - ): List> { - val loops = nodes.flatMap { it.getChildElementsByType(MultiInstanceLoopCharacteristics::class.java) } - val allExtensions = loops.flatMap { it.findExtensionElements() } - val loopCharacteristics = allExtensions.filterByType(ZeebeModelConstants.ELEMENT_LOOP_CHARACTERISTICS) - val inputElements = loopCharacteristics.extractAttribute(ZeebeModelConstants.ATTRIBUTE_INPUT_ELEMENT) - val inputCollections = loopCharacteristics.extractAttribute(ZeebeModelConstants.ATTRIBUTE_INPUT_COLLECTION) - val outputElements = loopCharacteristics.extractAttribute(ZeebeModelConstants.ATTRIBUTE_OUTPUT_ELEMENT) - val outputCollections = loopCharacteristics.extractAttribute(ZeebeModelConstants.ATTRIBUTE_OUTPUT_COLLECTION) - val inputs = (inputElements + inputCollections).map { Triple(it.removePrefix("="), VariableDirection.INPUT, it) } - val outputs = (outputElements + outputCollections).map { Triple(it.removePrefix("="), VariableDirection.OUTPUT, it) } - return inputs + outputs - } - -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/helpers/MessageSource.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/helpers/MessageSource.kt deleted file mode 100644 index 99390efd..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/helpers/MessageSource.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.helpers - -import org.camunda.bpm.model.bpmn.instance.Message - -data class MessageSource(val elementId: String?, val name: String?, val message: Message?) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/BaseElementUtils.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/BaseElementUtils.kt deleted file mode 100644 index 3d76f784..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/BaseElementUtils.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.utils - -import org.camunda.bpm.model.bpmn.instance.BaseElement -import org.camunda.bpm.model.xml.instance.ModelElementInstance - -object BaseElementUtils { - - fun BaseElement.findExtensionElement( - type: String, - ): ModelElementInstance? { - return this.findExtensionElementsWithType(type).firstOrNull() - } - - fun BaseElement.findExtensionElementsWithType( - type: String, - ): List { - val extensions = this.findExtensionElements() - return extensions.filter { it.elementType.typeName == type } - } - - fun BaseElement.findExtensionElements(): List { - return this.extensionElements?.elementsQuery?.list() ?: emptyList() - } -} \ No newline at end of file diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/DomElementUtils.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/DomElementUtils.kt deleted file mode 100644 index 9cd513f5..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/DomElementUtils.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.utils - -import org.camunda.bpm.model.xml.instance.DomElement - -object DomElementUtils { - - fun List.withElementName(vararg names: String): List { - return filter { names.contains(it.localName) } - } - - fun List.withAttribute(pair: Pair): List { - val (attributeName, expectedValue) = pair - return filter { it.getAttribute(attributeName) == expectedValue } - } - -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/EventDirectionUtils.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/EventDirectionUtils.kt deleted file mode 100644 index b6f5f1f4..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/EventDirectionUtils.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.utils - -import io.miragon.bpmn.domain.shared.EventDirection - -/** - * Maps a BPMN event element type name to its throw/catch role, shared by message and signal extraction. - * End / intermediate-throw events send; start / intermediate-catch / boundary events receive. Any other - * element type carries no throw/catch role and yields `null`. - */ -object EventDirectionUtils { - - fun fromElementTypeName(typeName: String): EventDirection? { - return when (typeName) { - "endEvent", "intermediateThrowEvent" -> EventDirection.THROW - "startEvent", "intermediateCatchEvent", "boundaryEvent" -> EventDirection.CATCH - else -> null - } - } -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/MessageUtils.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/MessageUtils.kt deleted file mode 100644 index a7569529..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/MessageUtils.kt +++ /dev/null @@ -1,59 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.utils - -import io.miragon.bpmn.adapter.outbound.engine.helpers.MessageSource -import io.miragon.bpmn.domain.shared.EventDirection -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants -import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition -import org.camunda.bpm.model.bpmn.instance.ReceiveTask -import org.camunda.bpm.model.xml.ModelInstance - -object MessageUtils { - - /** - * Couples each message-bearing node to its message name and throw/catch role, keyed by element id. - * Message events derive their role from the carrying event's shape (end / intermediate-throw = - * [EventDirection.THROW]; start / intermediate-catch / boundary = [EventDirection.CATCH]); a - * receive task is always a catcher. Nameless message nodes are skipped — they carry no name to - * correlate on and are the concern of the missing-message-name rule. - */ - fun ModelInstance.findMessageEventProperties(): Map { - val fromEvents = this.getModelElementsByType(MessageEventDefinition::class.java) - .mapNotNull { med -> - val name = med.message?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_NAME) ?: return@mapNotNull null - val parent = med.parentElement ?: return@mapNotNull null - val elementId = parent.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) ?: return@mapNotNull null - val direction = EventDirectionUtils.fromElementTypeName(parent.elementType.typeName) ?: return@mapNotNull null - elementId to FlowNodeProperties.MessageEvent(name, direction) - } - val fromTasks = this.getModelElementsByType(ReceiveTask::class.java) - .mapNotNull { task -> - val name = task.message?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_NAME) ?: return@mapNotNull null - val elementId = task.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) ?: return@mapNotNull null - elementId to FlowNodeProperties.MessageEvent(name, EventDirection.CATCH) - } - return (fromEvents + fromTasks).toMap() - } - - fun ModelInstance.findEventBasedMessagesWithSource(): List { - return this.getModelElementsByType(MessageEventDefinition::class.java) - .mapNotNull { med -> - val message = med.message ?: return@mapNotNull null - val elementId = med.parentElement?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val name = message.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_NAME) - MessageSource(elementId, name, message) - } - } - - fun ModelInstance.findTaskBasedMessagesWithSource(): List { - return this.getModelElementsByType(ReceiveTask::class.java) - .map { task -> - val elementId = task.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val name = task.message?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_NAME) - MessageSource(elementId, name, task.message) - } - } - - fun ModelInstance.findAllMessagesWithSource(): List = - findEventBasedMessagesWithSource() + findTaskBasedMessagesWithSource() -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/ModelElementInstanceUtils.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/ModelElementInstanceUtils.kt deleted file mode 100644 index 44f99c1d..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/ModelElementInstanceUtils.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.utils - -import org.camunda.bpm.model.xml.instance.ModelElementInstance - -object ModelElementInstanceUtils { - - fun List.findFirstByType(typeName: String): ModelElementInstance? { - return firstOrNull { it.elementType.typeName == typeName } - } - - fun List.filterByType(typeName: String): List { - return filter { it.elementType.typeName == typeName } - } - - fun List.extractAttribute(attributeName: String): List { - return mapNotNull { it.domElement.getAttribute(attributeName) } - } - - fun ModelElementInstance.nonBlankAttribute(name: String): String? { - return getAttributeValue(name)?.takeIf { it.isNotBlank() } - } - - fun ModelElementInstance.nonBlankAttributeNs(namespace: String, name: String): String? { - return getAttributeValueNs(namespace, name)?.takeIf { it.isNotBlank() } - } - -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/ModelInstanceUtils.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/ModelInstanceUtils.kt deleted file mode 100644 index a24d7fcb..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/ModelInstanceUtils.kt +++ /dev/null @@ -1,258 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.utils - -import io.miragon.bpmn.adapter.outbound.engine.constants.BpmnExtensionConstants -import io.miragon.bpmn.adapter.outbound.engine.utils.BaseElementUtils.findExtensionElements -import io.miragon.bpmn.domain.shared.BpmnNodeType -import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.CompensationType -import io.miragon.bpmn.domain.shared.ErrorDefinition -import io.miragon.bpmn.domain.shared.EscalationDefinition -import io.miragon.bpmn.domain.shared.EventDefinitionType -import io.miragon.bpmn.domain.shared.EventShape -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.GatewayKind -import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.SignalDefinition -import io.miragon.bpmn.domain.shared.SubProcessKind -import io.miragon.bpmn.domain.shared.TaskKind -import io.miragon.bpmn.domain.shared.TimerDefinition -import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants -import org.camunda.bpm.model.bpmn.instance.BoundaryEvent -import org.camunda.bpm.model.bpmn.instance.CompensateEventDefinition -import org.camunda.bpm.model.bpmn.instance.ErrorEventDefinition -import org.camunda.bpm.model.bpmn.instance.EscalationEventDefinition -import org.camunda.bpm.model.bpmn.instance.EventDefinition -import org.camunda.bpm.model.bpmn.instance.ExclusiveGateway -import org.camunda.bpm.model.bpmn.instance.FlowNode -import org.camunda.bpm.model.bpmn.instance.InclusiveGateway -import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition -import org.camunda.bpm.model.bpmn.instance.Process -import org.camunda.bpm.model.bpmn.instance.SequenceFlow -import org.camunda.bpm.model.bpmn.instance.SignalEventDefinition -import org.camunda.bpm.model.bpmn.instance.StartEvent -import org.camunda.bpm.model.bpmn.instance.SubProcess -import org.camunda.bpm.model.bpmn.instance.TimerEventDefinition -import org.camunda.bpm.model.xml.ModelInstance - -/** - * Utility functions for extracting BPMN elements that are common across process engines. - * Use this only if you have a method that can be used by multiple extractors. - */ -@Suppress("TooManyFunctions") -object ModelInstanceUtils { - - fun ModelInstance.getProcessId(): String { - val process = this.findProcess() - val processId = process.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - requireNotNull(processId) { "Process element is missing an 'id' attribute" } - return processId - } - - fun ModelInstance.isExecutable(): Boolean { - val process = this.findProcess() - val raw = process.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_IS_EXECUTABLE) - return raw?.toBoolean() ?: true - } - - fun ModelInstance.extractVariantName(): String? { - val process = this.findProcess() - val extensions = process.findExtensionElements() - val propertiesContainers = extensions.filter { it.domElement.localName == "properties" } - val allProperties = propertiesContainers.flatMap { it.domElement.childElements } - val variantProperty = allProperties - .filter { it.localName == "property" } - .firstOrNull { it.getAttribute("name") == BpmnExtensionConstants.VARIANT_NAME_PROPERTY_NAME } - return variantProperty?.getAttribute("value")?.takeIf { it.isNotBlank() } - } - - private fun ModelInstance.findProcess(): Process { - val process = this.getModelElementsByType(Process::class.java).firstOrNull() - requireNotNull(process) { "BPMN model does not contain a Process element" } - return process - } - - fun ModelInstance.findFlowNodes(): List { - val flowNodes = this.getModelElementsByType(FlowNode::class.java) - return flowNodes.map { - val id = it.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val name = it.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_NAME)?.normalizeWhitespace() - val nodeType = it.resolveNodeType() - val attachedToRef = if (it is BoundaryEvent) it.attachedTo?.id else null - val interrupting = it.resolveInterrupting() - val parentId = (it.parentElement as? SubProcess)?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val previousElements = it.incoming.mapNotNull { flow -> flow.source?.id } - val followingElements = it.outgoing.mapNotNull { flow -> flow.target?.id } - FlowNodeDefinition( - id = id, - displayName = name, - nodeType = nodeType, - attachedToRef = attachedToRef, - interrupting = interrupting, - parentId = parentId, - previousElements = previousElements, - followingElements = followingElements, - ) - } - } - - fun ModelInstance.findSequenceFlows(): List { - val defaultFlowIds = buildDefaultFlowIdSet() - val sequenceFlows = this.getModelElementsByType(SequenceFlow::class.java) - return sequenceFlows.mapNotNull { flow -> - val id = flow.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val sourceRef = flow.source?.id ?: return@mapNotNull null - val targetRef = flow.target?.id ?: return@mapNotNull null - val flowName = flow.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_NAME)?.normalizeWhitespace()?.takeIf { it.isNotBlank() } - val condition = flow.conditionExpression?.textContent?.takeIf { it.isNotBlank() } - SequenceFlowDefinition( - id = id, - sourceRef = sourceRef, - targetRef = targetRef, - flowName = flowName, - conditionExpression = condition, - isDefault = id != null && id in defaultFlowIds, - ) - } - } - - private fun ModelInstance.buildDefaultFlowIdSet(): Set { - val exclusiveDefaults = getModelElementsByType(ExclusiveGateway::class.java).mapNotNull { it.default?.id } - val inclusiveDefaults = getModelElementsByType(InclusiveGateway::class.java).mapNotNull { it.default?.id } - return (exclusiveDefaults + inclusiveDefaults).toSet() - } - - fun ModelInstance.findErrorEventDefinition(): List { - val errorEvents = this.getModelElementsByType(ErrorEventDefinition::class.java) - return errorEvents.map { - val elementId = it.parentElement?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - ErrorDefinition(id = elementId, name = it.error?.name, code = it.error?.errorCode) - } - } - - fun ModelInstance.findEscalationEventDefinitions(): List { - val escalationEvents = this.getModelElementsByType(EscalationEventDefinition::class.java) - return escalationEvents.map { - val elementId = it.parentElement?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - EscalationDefinition(id = elementId, name = it.escalation?.name, code = it.escalation?.escalationCode) - } - } - - fun ModelInstance.findCompensateEventDefinitions(): List { - val compensateEvents = this.getModelElementsByType(CompensateEventDefinition::class.java) - return compensateEvents.map { - val elementId = it.parentElement?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val type = if (it.parentElement is BoundaryEvent) CompensationType.CATCHING else CompensationType.THROWING - CompensationDefinition( - id = elementId, - type = type, - engineSpecificProperties = buildMap { - it.activity?.id?.let { ref -> put(CompensationDefinition.ACTIVITY_REF_KEY, ref) } - put(CompensationDefinition.WAIT_FOR_COMPLETION_KEY, it.isWaitForCompletion) - }, - ) - } - } - - fun ModelInstance.findSignalEventDefinitions(): List { - val signalEvents = this.getModelElementsByType(SignalEventDefinition::class.java) - return signalEvents.map { - val elementId = it.parentElement?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val name = it.signal?.name - SignalDefinition(id = elementId, name = name) - } - } - - fun ModelInstance.findTimerEventDefinition(): List { - val timerEvents = this.getModelElementsByType(TimerEventDefinition::class.java) - return timerEvents.map { - val timerId = it.parentElement?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) - val timerTypeValue = it.detectTimerType() - TimerDefinition(id = timerId, type = timerTypeValue?.first, value = timerTypeValue?.second) - } - } - - private fun TimerEventDefinition.detectTimerType(): Pair? { - return if (this.timeDate != null) { - Pair("Date", this.timeDate.textContent) - } else if (this.timeDuration != null) { - Pair("Duration", this.timeDuration.textContent) - } else if (this.timeCycle != null) { - Pair("Cycle", this.timeCycle.textContent) - } else { - null - } - } - - private fun String.normalizeWhitespace(): String = this.replace(Regex("\\s+"), " ").trim() - - private val nonEventNodeTypes: Map = mapOf( - "serviceTask" to BpmnNodeType.Activity.Task(TaskKind.SERVICE), - "userTask" to BpmnNodeType.Activity.Task(TaskKind.USER), - "receiveTask" to BpmnNodeType.Activity.Task(TaskKind.RECEIVE), - "sendTask" to BpmnNodeType.Activity.Task(TaskKind.SEND), - "scriptTask" to BpmnNodeType.Activity.Task(TaskKind.SCRIPT), - "manualTask" to BpmnNodeType.Activity.Task(TaskKind.MANUAL), - "businessRuleTask" to BpmnNodeType.Activity.Task(TaskKind.BUSINESS_RULE), - "task" to BpmnNodeType.Activity.Task(TaskKind.NONE), - "exclusiveGateway" to BpmnNodeType.Gateway(GatewayKind.EXCLUSIVE), - "parallelGateway" to BpmnNodeType.Gateway(GatewayKind.PARALLEL), - "inclusiveGateway" to BpmnNodeType.Gateway(GatewayKind.INCLUSIVE), - "eventBasedGateway" to BpmnNodeType.Gateway(GatewayKind.EVENT_BASED), - "complexGateway" to BpmnNodeType.Gateway(GatewayKind.COMPLEX), - "subProcess" to BpmnNodeType.Activity.SubProcess(SubProcessKind.PLAIN), - "callActivity" to BpmnNodeType.Activity.CallActivity, - "transaction" to BpmnNodeType.Activity.SubProcess(SubProcessKind.TRANSACTION), - ) - - private fun FlowNode.resolveNodeType(): BpmnNodeType { - val eventShape = resolveEventShape() - return if (this is SubProcess && this.triggeredByEvent()) { - BpmnNodeType.Activity.SubProcess(SubProcessKind.EVENT) - } else if (eventShape != null) { - BpmnNodeType.Event(eventShape, resolveEventDefinitionType()) - } else { - nonEventNodeTypes[this.elementType.typeName] ?: BpmnNodeType.Unknown - } - } - - /** - * Whether the event interrupts its enclosing scope, defaulting to `true` per the BPMN spec - * when the attribute is absent. Meaningful only for boundary events (`cancelActivity`) and - * event sub-process start events (`isInterrupting`); `null` for every other node. - */ - private fun FlowNode.resolveInterrupting(): Boolean? = when { - this is BoundaryEvent -> cancelActivity() - this is StartEvent && (parentElement as? SubProcess)?.triggeredByEvent() == true -> isInterrupting - else -> null - } - - private fun FlowNode.resolveEventShape(): EventShape? = when (this.elementType.typeName) { - "startEvent" -> EventShape.START_EVENT - "endEvent" -> EventShape.END_EVENT - "intermediateCatchEvent" -> EventShape.INTERMEDIATE_CATCH_EVENT - "intermediateThrowEvent" -> EventShape.INTERMEDIATE_THROW_EVENT - "boundaryEvent" -> EventShape.BOUNDARY_EVENT - else -> null - } - - /** - * Resolves the event's definition kind from its child `<…EventDefinition>` element. Catch events - * (start/intermediate-catch/boundary) are *triggered* by it; throw events (intermediate-throw/end) - * describe a *result* with it. Non-events simply have no such child and resolve to NONE. - */ - private fun FlowNode.resolveEventDefinitionType(): EventDefinitionType { - return getChildElementsByType(EventDefinition::class.java) - .firstNotNullOfOrNull { it.toEventDefinitionType() } ?: EventDefinitionType.NONE - } - - private fun EventDefinition.toEventDefinitionType(): EventDefinitionType? = when (this) { - is TimerEventDefinition -> EventDefinitionType.TIMER - is MessageEventDefinition -> EventDefinitionType.MESSAGE - is ErrorEventDefinition -> EventDefinitionType.ERROR - is SignalEventDefinition -> EventDefinitionType.SIGNAL - is EscalationEventDefinition -> EventDefinitionType.ESCALATION - is CompensateEventDefinition -> EventDefinitionType.COMPENSATION - else -> null - } - -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/SignalUtils.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/SignalUtils.kt deleted file mode 100644 index 015b3b59..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/utils/SignalUtils.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.utils - -import io.miragon.bpmn.domain.shared.EventDirection -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import org.camunda.bpm.model.bpmn.impl.BpmnModelConstants -import org.camunda.bpm.model.bpmn.instance.SignalEventDefinition -import org.camunda.bpm.model.xml.ModelInstance - -object SignalUtils { - - /** - * Couples each signal-bearing node to its signal name and throw/catch role, keyed by element id. - * Signal events derive their role from the carrying event's shape (end / intermediate-throw = - * [EventDirection.THROW]; start / intermediate-catch / boundary = [EventDirection.CATCH]). Nameless - * signal nodes are skipped — they carry no name to correlate on and are the concern of the - * missing-signal-name rule. - */ - fun ModelInstance.findSignalEventProperties(): Map { - return this.getModelElementsByType(SignalEventDefinition::class.java) - .mapNotNull { sed -> - val name = sed.signal?.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_NAME) ?: return@mapNotNull null - val parent = sed.parentElement ?: return@mapNotNull null - val elementId = parent.getAttributeValue(BpmnModelConstants.BPMN_ATTRIBUTE_ID) ?: return@mapNotNull null - val direction = EventDirectionUtils.fromElementTypeName(parent.elementType.typeName) ?: return@mapNotNull null - elementId to FlowNodeProperties.SignalEvent(name, direction) - } - .toMap() - } -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/CamundaXmlApi.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/CamundaXmlApi.kt new file mode 100644 index 00000000..e682a621 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/CamundaXmlApi.kt @@ -0,0 +1,56 @@ +package io.miragon.bpmn.adapter.outbound.engine.xml + +import org.camunda.bpm.model.bpmn.instance.BaseElement +import org.camunda.bpm.model.xml.instance.DomElement +import org.camunda.bpm.model.xml.instance.ModelElementInstance + +/** + * The handful of lookups the `camunda-xml-model` API does not offer directly: + * Reaching into bpmn:extensionElements`, filtering by element type or name, and reading attributes that may be blank. + * + * Everything here is a thin projection over the parser's own types. Anything that interprets what it finds + * belongs to a dialect instead. + */ +internal object CamundaXmlApi { + + fun BaseElement.findExtensionElements(): List { + return this.extensionElements?.elementsQuery?.list() ?: emptyList() + } + + fun BaseElement.findExtensionElementsWithType(type: String): List { + return this.findExtensionElements().filterByType(type) + } + + fun BaseElement.findExtensionElement(type: String): ModelElementInstance? { + return this.findExtensionElementsWithType(type).firstOrNull() + } + + fun List.findFirstByType(typeName: String): ModelElementInstance? { + return firstOrNull { it.elementType.typeName == typeName } + } + + fun List.filterByType(typeName: String): List { + return filter { it.elementType.typeName == typeName } + } + + fun List.extractAttribute(attributeName: String): List { + return mapNotNull { it.domElement.getAttribute(attributeName) } + } + + fun ModelElementInstance.nonBlankAttribute(name: String): String? { + return getAttributeValue(name)?.takeIf { it.isNotBlank() } + } + + fun ModelElementInstance.nonBlankAttributeNs(namespace: String, name: String): String? { + return getAttributeValueNs(namespace, name)?.takeIf { it.isNotBlank() } + } + + fun List.withElementName(vararg names: String): List { + return filter { names.contains(it.localName) } + } + + fun List.withAttribute(pair: Pair): List { + val (attributeName, expectedValue) = pair + return filter { it.getAttribute(attributeName) == expectedValue } + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/ForeignXmlReader.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/ForeignXmlReader.kt new file mode 100644 index 00000000..64639cf6 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/ForeignXmlReader.kt @@ -0,0 +1,122 @@ +package io.miragon.bpmn.adapter.outbound.engine.xml + +import io.miragon.bpmn.domain.shared.EngineExtension +import org.camunda.bpm.model.xml.ModelInstance +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.Node + +/** + * Projects foreign-namespace XML — everything a BPMN file carries beyond the OMG schema — into the + * engine-agnostic [EngineExtension] shape, plus the foreign-namespace *attributes* of an element. + * + * Camunda's typed model API only exposes attributes it declares, so anything an engine adds that we have + * not modelled would be invisible. Reading the underlying W3C document instead keeps the projection + * lossless and future-proof: a new `zeebe:` or `camunda:` element shows up without a code change. + */ +internal class ForeignXmlReader( + modelInstance: ModelInstance, + private val engineNamespace: String = "", + private val fullyReadExtensions: Set = emptySet(), +) { + + private val elementsById: Map = indexById(modelInstance.document.domSource.node) + + /** + * The `bpmn:extensionElements` children of the element with [elementId], projected recursively. + */ + fun extensionsOf(elementId: String?): List { + val element = elementsById[elementId] ?: return emptyList() + return element.childElements() + .filter { it.localNameOf() == EXTENSION_ELEMENTS && it.namespaceURI in BPMN_NAMESPACES } + .flatMap { it.childElements() } + .filterNot { it.isFullyReadByTheDialect() } + .map { it.toExtension() } + } + + /** + * Attributes of the element with [elementId] that live in a non-BPMN namespace — Camunda 7's + * `camunda:asyncBefore`, Operaton's `operaton:exclusive`, and anything else an engine adds. + * Keys keep their `prefix:localName` form so provenance is never lost. Values are typed where the + * literal is unambiguous (`true` / `false` / an integer), and kept as strings otherwise. + */ + fun foreignAttributesOf(elementId: String?, fullyRead: Set = emptySet()): Map { + val element = elementsById[elementId] ?: return emptyMap() + val attributes = element.attributes ?: return emptyMap() + return (0 until attributes.length) + .map { attributes.item(it) } + .filter { it.namespaceURI != null && it.namespaceURI !in IGNORED_NAMESPACES } + .filterNot { it.namespaceURI == engineNamespace && it.localNameOf() in fullyRead } + .associate { it.qualifiedName() to it.nodeValue.toTypedValue() } + } + + /** + * Whether the dialect already read this element into a typed field, in which case reporting it here + * would duplicate it. Matched on namespace *and* local name, so an identically named element from + * another engine is unaffected. + */ + private fun Element.isFullyReadByTheDialect(): Boolean { + return namespaceURI == engineNamespace && localNameOf() in fullyReadExtensions + } + + private fun Element.toExtension(): EngineExtension { + val children = childElements() + return EngineExtension( + type = qualifiedName(), + attributes = ownAttributes(), + children = children.map { it.toExtension() }, + body = textContent.takeIf { children.isEmpty() && it.isNotBlank() }?.trim(), + ) + } + + private fun Element.ownAttributes(): Map { + val attributes = attributes ?: return emptyMap() + return (0 until attributes.length) + .map { attributes.item(it) } + .filter { it.namespaceURI !in IGNORED_NAMESPACES } + .associate { it.qualifiedName() to it.nodeValue } + } + + private fun indexById(root: Node): Map { + val document = root as? Document ?: return emptyMap() + return buildMap { collectById(document.documentElement, this) } + } + + private fun collectById(element: Element, target: MutableMap) { + element.getAttribute(ID_ATTRIBUTE).takeIf { it.isNotBlank() }?.let { target.putIfAbsent(it, element) } + element.childElements().forEach { collectById(it, target) } + } + + private fun Element.childElements(): List { + val nodes = childNodes + return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element } + } + + private fun Node.qualifiedName(): String { + val local = localNameOf() + return prefix?.let { "$it:$local" } ?: local + } + + private fun Node.localNameOf(): String = localName ?: nodeName + + private fun String?.toTypedValue(): Any? = when { + this == null -> null + equals("true", ignoreCase = true) -> true + equals("false", ignoreCase = true) -> false + else -> toLongOrNull() ?: this + } + + private companion object { + const val ID_ATTRIBUTE = "id" + const val EXTENSION_ELEMENTS = "extensionElements" + + val BPMN_NAMESPACES = setOf( + "http://www.omg.org/spec/BPMN/20100524/MODEL", + ) + + val IGNORED_NAMESPACES = setOf( + "http://www.w3.org/2000/xmlns/", + "http://www.w3.org/2001/XMLSchema-instance", + ) + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/SecureBpmnParser.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/SecureBpmnParser.kt similarity index 67% rename from bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/SecureBpmnParser.kt rename to bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/SecureBpmnParser.kt index cc43f15d..bd8c4b65 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/SecureBpmnParser.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/SecureBpmnParser.kt @@ -1,13 +1,13 @@ -package io.miragon.bpmn.adapter.outbound.engine +package io.miragon.bpmn.adapter.outbound.engine.xml +import java.io.InputStream +import javax.xml.parsers.SAXParserFactory import org.camunda.bpm.model.bpmn.Bpmn import org.camunda.bpm.model.bpmn.BpmnModelInstance import org.xml.sax.Attributes import org.xml.sax.SAXException import org.xml.sax.SAXParseException import org.xml.sax.helpers.DefaultHandler -import java.io.InputStream -import javax.xml.parsers.SAXParserFactory /** * Camunda's Bpmn.readModelFromStream does not disable external entity resolution, making it @@ -16,8 +16,10 @@ import javax.xml.parsers.SAXParserFactory */ internal object SecureBpmnParser { + private const val DISALLOW_DOCTYPE_FEATURE = "http://apache.org/xml/features/disallow-doctype-decl" + private val saxFactory = SAXParserFactory.newInstance().also { factory -> - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + factory.setFeature(DISALLOW_DOCTYPE_FEATURE, true) } fun readModelFromBytes(bytes: ByteArray): BpmnModelInstance { @@ -38,8 +40,13 @@ internal object SecureBpmnParser { } catch (_: EarlyAbortException) { return // clean exit — no DOCTYPE found } catch (e: SAXParseException) { - // disallow-doctype-decl throws SAXParseException when DOCTYPE is encountered - throw SecurityException("DOCTYPE declarations are not allowed in BPMN files", e) + // The same exception type covers both the security check firing and ordinary malformed XML. + // Only the former is a security problem; calling a truncated file a DOCTYPE violation sends + // the reader looking for something that is not there. + if (e.message?.contains(DISALLOW_DOCTYPE_FEATURE) == true) { + throw SecurityException("DOCTYPE declarations are not allowed in BPMN files", e) + } + throw IllegalArgumentException("File is not well-formed XML: ${e.message}", e) } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/BpmnFileLoader.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/BpmnFileLoader.kt index 8cfe016b..a106ebf4 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/BpmnFileLoader.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/BpmnFileLoader.kt @@ -1,8 +1,8 @@ package io.miragon.bpmn.adapter.outbound.filesystem +import io.github.oshai.kotlinlogging.KotlinLogging import io.miragon.bpmn.application.port.outbound.LoadBpmnFilesPort import io.miragon.bpmn.domain.BpmnResource -import io.github.oshai.kotlinlogging.KotlinLogging import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path @@ -12,7 +12,7 @@ import kotlin.io.path.name import kotlin.io.path.readBytes import kotlin.streams.toList -class BpmnFileLoader : LoadBpmnFilesPort { +internal class BpmnFileLoader : LoadBpmnFilesPort { private val logger = KotlinLogging.logger {} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/ProcessApiFileSaver.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/ProcessApiFileSaver.kt index 36fd1731..1370680d 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/ProcessApiFileSaver.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/ProcessApiFileSaver.kt @@ -1,11 +1,11 @@ package io.miragon.bpmn.adapter.outbound.filesystem +import io.github.oshai.kotlinlogging.KotlinLogging import io.miragon.bpmn.application.port.outbound.SaveProcessApiPort import io.miragon.bpmn.domain.GeneratedApiFile -import io.github.oshai.kotlinlogging.KotlinLogging import java.io.File -class ProcessApiFileSaver : SaveProcessApiPort { +internal class ProcessApiFileSaver : SaveProcessApiPort { private val logger = KotlinLogging.logger {} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/ProcessJsonFileSaver.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/ProcessJsonFileSaver.kt index 939ac796..6b66a70f 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/ProcessJsonFileSaver.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/filesystem/ProcessJsonFileSaver.kt @@ -1,11 +1,11 @@ package io.miragon.bpmn.adapter.outbound.filesystem +import io.github.oshai.kotlinlogging.KotlinLogging import io.miragon.bpmn.application.port.outbound.SaveProcessJsonPort import io.miragon.bpmn.domain.GeneratedJsonFile -import io.github.oshai.kotlinlogging.KotlinLogging import java.io.File -class ProcessJsonFileSaver : SaveProcessJsonPort { +internal class ProcessJsonFileSaver : SaveProcessJsonPort { private val logger = KotlinLogging.logger {} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonGenerationAdapter.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonGenerationAdapter.kt index 87a2b4af..55073274 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonGenerationAdapter.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonGenerationAdapter.kt @@ -4,7 +4,7 @@ import io.miragon.bpmn.application.port.outbound.GenerateJsonPort import io.miragon.bpmn.domain.GeneratedJsonFile import io.miragon.bpmn.domain.ProcessModel -class BpmnJsonGenerationAdapter( +internal class BpmnJsonGenerationAdapter( private val jsonGenerator: BpmnJsonGenerator = BpmnJsonGenerator(), ) : GenerateJsonPort { diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonGenerator.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonGenerator.kt index ce00e5ea..72651f6b 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonGenerator.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonGenerator.kt @@ -1,17 +1,20 @@ package io.miragon.bpmn.adapter.outbound.json -import io.miragon.bpmn.adapter.outbound.json.model.BpmnModelJson +import io.miragon.bpmn.adapter.outbound.json.model.ProcessModelJson import io.miragon.bpmn.domain.ProcessModel import kotlinx.serialization.json.Json -class BpmnJsonGenerator( +internal class BpmnJsonGenerator( private val mapper: BpmnJsonMapper = BpmnJsonMapper(), ) { - private val json = Json { prettyPrint = true } + private val json = Json { + prettyPrint = true + explicitNulls = false + } fun generate(model: ProcessModel): String { val dto = mapper.toJson(model) - return json.encodeToString(BpmnModelJson.serializer(), dto) + return json.encodeToString(ProcessModelJson.serializer(), dto) } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonMapper.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonMapper.kt index 22b60b64..74ce857c 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonMapper.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/BpmnJsonMapper.kt @@ -1,132 +1,190 @@ package io.miragon.bpmn.adapter.outbound.json -import io.miragon.bpmn.adapter.outbound.json.model.BpmnModelJson -import io.miragon.bpmn.adapter.outbound.json.model.CompensationJson -import io.miragon.bpmn.adapter.outbound.json.model.EscalationJson -import io.miragon.bpmn.adapter.outbound.json.model.ErrorJson +import io.miragon.bpmn.adapter.outbound.json.model.CalledElementJson +import io.miragon.bpmn.adapter.outbound.json.model.DefinitionsJson +import io.miragon.bpmn.adapter.outbound.json.model.EventDefinitionJson +import io.miragon.bpmn.adapter.outbound.json.model.ExtensionJson import io.miragon.bpmn.adapter.outbound.json.model.FlowNodeJson -import io.miragon.bpmn.adapter.outbound.json.model.FlowNodePropertiesJson -import io.miragon.bpmn.adapter.outbound.json.model.MessageJson +import io.miragon.bpmn.adapter.outbound.json.model.ImplementationJson +import io.miragon.bpmn.adapter.outbound.json.model.IoMappingJson +import io.miragon.bpmn.adapter.outbound.json.model.MultiInstanceJson +import io.miragon.bpmn.adapter.outbound.json.model.ProcessJson +import io.miragon.bpmn.adapter.outbound.json.model.ProcessModelJson import io.miragon.bpmn.adapter.outbound.json.model.SequenceFlowJson -import io.miragon.bpmn.adapter.outbound.json.model.SignalJson +import io.miragon.bpmn.adapter.outbound.json.model.VariableJson import io.miragon.bpmn.adapter.outbound.json.model.VariantJson -import io.miragon.bpmn.adapter.outbound.shared.ElementTypeName -import io.miragon.bpmn.domain.BpmnModel -import io.miragon.bpmn.domain.MergedBpmnModel +import io.miragon.bpmn.adapter.outbound.shared.BpmnTypeName import io.miragon.bpmn.domain.ProcessModel -import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.ErrorDefinition -import io.miragon.bpmn.domain.shared.EscalationDefinition +import io.miragon.bpmn.domain.shared.EngineExtension +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition +import io.miragon.bpmn.domain.shared.IoMapping +import io.miragon.bpmn.domain.shared.MultiInstanceDefinition +import io.miragon.bpmn.domain.shared.RootElementDefinition +import io.miragon.bpmn.domain.shared.RootElements import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.SignalDefinition +import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.VariableDefinition import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive -class BpmnJsonMapper { - - fun toJson(model: ProcessModel): BpmnModelJson { - return when (model) { - is BpmnModel -> toFlatJson(model) - is MergedBpmnModel -> toVariantJson(model) - } - } - - private fun toFlatJson(model: BpmnModel): BpmnModelJson { - val messageEngineProperties = model.messages.messageEnginePropertiesByNode() - return BpmnModelJson( - processId = model.processId, - flowNodes = FlowNodeSorter.sort(model.flowNodes).map { it.toJson(messageEngineProperties) }, - sequenceFlows = model.sequenceFlows.map { it.toJson() }, - messages = model.messages.mapNotNull { it.toJson() }, - signals = model.signals.mapNotNull { it.toJson() }, - errors = model.errors.mapNotNull { it.toJson() }, - escalations = model.escalations.mapNotNull { it.toJson() }, - compensations = model.compensations.mapNotNull { it.toJson() }, +/** + * Maps the domain model onto the public process-JSON contract (format 2.0, see ADR 018). + * + * Each scope is emitted with its own nodes and sequence flows, so nesting is structural rather than + * inferred, and every node is sorted into process-flow order within its scope. + */ +@Suppress("TooManyFunctions") +internal class BpmnJsonMapper { + + fun toJson(model: ProcessModel): ProcessModelJson { + return ProcessModelJson( + process = ProcessJson( + id = model.processId, + name = model.processName, + isExecutable = model.isExecutable, + engine = model.detectedEngine?.name, + flowNodes = model.flowNodes.toJson(model.sequenceFlows), + sequenceFlows = model.sequenceFlows.map { it.toJson() }, + ), + definitions = model.definitions.toJson(), + variants = model.variants.map { it.toJson() }.takeIf { it.isNotEmpty() }, + ) + } + + private fun ProcessModel.Variant.toJson(): VariantJson { + return VariantJson( + name = variantName, + flowNodes = flowNodes.toJson(sequenceFlows), + sequenceFlows = sequenceFlows.map { it.toJson() }, ) } - private fun toVariantJson(model: MergedBpmnModel): BpmnModelJson { - val messageEngineProperties = model.messages.messageEnginePropertiesByNode() - return BpmnModelJson( - processId = model.processId, - messages = model.messages.mapNotNull { it.toJson() }, - signals = model.signals.mapNotNull { it.toJson() }, - errors = model.errors.mapNotNull { it.toJson() }, - escalations = model.escalations.mapNotNull { it.toJson() }, - compensations = model.compensations.mapNotNull { it.toJson() }, - variants = model.variants.map { variant -> - VariantJson( - variantName = variant.variantName, - flowNodes = FlowNodeSorter.sort(variant.flowNodes).map { it.toJson(messageEngineProperties) }, - sequenceFlows = variant.sequenceFlows.map { it.toJson() }, - ) - }, + private fun RootElements.toJson(): DefinitionsJson { + return DefinitionsJson( + messages = messages.mapNotNull { it.toJson() }.sortedBy { it.id }, + signals = signals.mapNotNull { it.toJson() }.sortedBy { it.id }, + errors = errors.mapNotNull { it.toJson() }.sortedBy { it.id }, + escalations = escalations.mapNotNull { it.toJson() }.sortedBy { it.id }, ) } - private fun List.messageEnginePropertiesByNode(): Map> { - return associate { it.id to it.engineSpecificProperties } + private fun List.toJson(sequenceFlows: List): List { + return FlowNodeSorter.sort(this, sequenceFlows).map { it.toJson() } } - private fun FlowNodeDefinition.toJson(messageEngineProperties: Map>): FlowNodeJson { + private fun FlowNodeDefinition.toJson(): FlowNodeJson { + val activity = this as? FlowNodeDefinition.Activity + val event = this as? FlowNodeDefinition.Event + val subProcess = this as? FlowNodeDefinition.Activity.SubProcess return FlowNodeJson( id = id ?: "", - displayName = displayName, - elementType = ElementTypeName.of(nodeType), - parentId = parentId, - attachedToRef = attachedToRef, - interrupting = interrupting, - attachedElements = attachedElements, - previousElements = previousElements, - followingElements = followingElements, - variables = variables.map { it.getRawName() }, - properties = properties.toJson(messageEngineProperties[id].orEmpty()), - engineSpecificProperties = engineSpecificProperties.mapValues { (_, v) -> v.toJsonElement() }, + type = BpmnTypeName.of(this), + name = displayName, + incoming = incoming, + outgoing = outgoing, + default = defaultFlow(), + attachedToRef = event?.attachedToRef, + cancelActivity = event?.takeIf { it.shape == EventShape.BOUNDARY_EVENT }?.interrupting, + isInterrupting = event?.takeIf { it.shape == EventShape.START_EVENT }?.interrupting, + triggeredByEvent = subProcess?.takeIf { it.kind == SubProcessKind.EVENT }?.let { true }, + isForCompensation = activity?.isForCompensation?.takeIf { it }, + boundaryEventRefs = activity?.boundaryEventRefs.orEmpty(), + eventDefinitions = event?.eventDefinitions?.map { it.toJson() }.orEmpty(), + messageRef = (this as? FlowNodeDefinition.Activity.Task)?.message?.messageRef, + implementation = implementation()?.toJson(), + calledElement = (this as? FlowNodeDefinition.Activity.CallActivity)?.toCalledElement(), + multiInstance = activity?.multiInstance?.toJson(), + ioMapping = ioMapping()?.toJson(), + variables = variables.map { it.toJson() }, + flowNodes = subProcess?.flowNodes?.toJson(subProcess.sequenceFlows).orEmpty(), + sequenceFlows = subProcess?.sequenceFlows?.map { it.toJson() }.orEmpty(), + extensions = extensions.map { it.toJson() }, + engineAttributes = engineAttributes.mapValues { (_, value) -> value.toJsonElement() }, ) } - private fun Any?.toJsonElement(): JsonElement = when (this) { - null -> JsonNull - is Boolean -> JsonPrimitive(this) - is Number -> JsonPrimitive(this) - is String -> JsonPrimitive(this) - else -> JsonPrimitive(this.toString()) + private fun FlowNodeDefinition.defaultFlow(): String? = when (this) { + is FlowNodeDefinition.Gateway -> defaultFlow + is FlowNodeDefinition.Activity -> defaultFlow + else -> null + } + + private fun FlowNodeDefinition.implementation(): TaskImplementation? = when (this) { + is FlowNodeDefinition.Activity.Task -> implementation + is FlowNodeDefinition.Event -> implementation + else -> null } - private fun FlowNodeProperties.toJson(messageEngineProperties: Map): FlowNodePropertiesJson? = when (this) { - is FlowNodeProperties.None -> null - is FlowNodeProperties.ServiceTask -> FlowNodePropertiesJson( - type = "ServiceTask", - implementationValue = definition.engineSpecificProperties[ServiceTaskDefinition.IMPL_VALUE_KEY] as? String, - implementationKind = definition.engineSpecificProperties[ServiceTaskDefinition.IMPL_KIND_KEY] as? String, + private fun FlowNodeDefinition.ioMapping(): IoMapping? = when (this) { + is FlowNodeDefinition.Activity -> ioMapping + is FlowNodeDefinition.Event -> ioMapping + else -> null + } + + private fun FlowNodeDefinition.Activity.CallActivity.toCalledElement(): CalledElementJson? { + val calledElement = CalledElementJson( + processId = definition.getValue().takeIf { it.isNotEmpty() }, + propagateAllInputVariables = definition.propagateAllInputVariables, + propagateAllOutputVariables = definition.propagateAllOutputVariables, ) - is FlowNodeProperties.CallActivity -> FlowNodePropertiesJson( - type = "CallActivity", - calledElement = definition.getValue(), + return calledElement.takeIf { it != CalledElementJson() } + } + + private fun EventDefinitionInstance.toJson(): EventDefinitionJson = when (this) { + is EventDefinitionInstance.Timer -> EventDefinitionJson.Timer(timerType?.name, expression) + is EventDefinitionInstance.Message -> EventDefinitionJson.Message(reference.messageRef) + is EventDefinitionInstance.Signal -> EventDefinitionJson.Signal(signalRef) + is EventDefinitionInstance.Error -> EventDefinitionJson.Error(errorRef) + is EventDefinitionInstance.Escalation -> EventDefinitionJson.Escalation(escalationRef) + is EventDefinitionInstance.Compensation -> EventDefinitionJson.Compensation(activityRef, waitForCompletion) + is EventDefinitionInstance.Conditional -> EventDefinitionJson.Conditional(expression) + is EventDefinitionInstance.Link -> EventDefinitionJson.Link(linkName) + is EventDefinitionInstance.Terminate -> EventDefinitionJson.Terminate + } + + private fun TaskImplementation.toJson(): ImplementationJson? = when (this) { + is TaskImplementation.Unspecified -> null + is TaskImplementation.JobWorker -> ImplementationJson.JobWorker(jobType, retries) + is TaskImplementation.Connector -> ImplementationJson.Connector(jobType, templateId, retries) + is TaskImplementation.ExternalTask -> ImplementationJson.ExternalTask(topic) + is TaskImplementation.JavaClass -> ImplementationJson.JavaClass(className) + is TaskImplementation.DelegateExpression -> ImplementationJson.DelegateExpression(expression) + is TaskImplementation.Expression -> ImplementationJson.Expression(expression) + } + + private fun MultiInstanceDefinition.toJson(): MultiInstanceJson { + return MultiInstanceJson( + sequential = sequential, + inputCollection = inputCollection, + inputElement = inputElement, + outputCollection = outputCollection, + outputElement = outputElement, + cardinality = cardinality, + completionCondition = completionCondition, ) - is FlowNodeProperties.Timer -> { - val (type, value) = definition.getValue() - FlowNodePropertiesJson( - type = "Timer", - timerType = type.takeIf { it.isNotEmpty() }, - timerValue = value.takeIf { it.isNotEmpty() }, - ) - } - is FlowNodeProperties.MessageEvent -> FlowNodePropertiesJson( - type = "MessageEvent", - messageName = name, - messageDirection = direction.name, - engineSpecificProperties = messageEngineProperties.mapValues { (_, v) -> v.toJsonElement() }, + } + + private fun IoMapping.toJson(): IoMappingJson { + return IoMappingJson( + inputs = inputs.map { IoMappingJson.Parameter(it.target, it.source) }, + outputs = outputs.map { IoMappingJson.Parameter(it.target, it.source) }, ) - is FlowNodeProperties.SignalEvent -> FlowNodePropertiesJson( - type = "SignalEvent", - signalName = name, - signalDirection = direction.name, + } + + private fun VariableDefinition.toJson(): VariableJson { + return VariableJson(name = getRawName(), direction = direction.name, expression = valueExpression) + } + + private fun EngineExtension.toJson(): ExtensionJson { + return ExtensionJson( + type = type, + attributes = attributes, + children = children.map { it.toJson() }, + body = body, ) } @@ -137,34 +195,36 @@ class BpmnJsonMapper { targetRef = targetRef, name = flowName, conditionExpression = conditionExpression, - isDefault = isDefault, ) } - private fun MessageDefinition.toJson(): MessageJson? { + private fun RootElementDefinition.Message.toJson(): DefinitionsJson.Message? { val name = getValue().takeIf { it.isNotEmpty() } ?: return null - return MessageJson(id = id ?: "", name = name) + return DefinitionsJson.Message(id = id ?: name, name = name, correlationKey = correlationKey) } - private fun SignalDefinition.toJson(): SignalJson? { + private fun RootElementDefinition.Signal.toJson(): DefinitionsJson.Signal? { val name = getValue().takeIf { it.isNotEmpty() } ?: return null - return SignalJson(id = id ?: "", name = name) + return DefinitionsJson.Signal(id = id ?: name, name = name) } - private fun ErrorDefinition.toJson(): ErrorJson? { + private fun RootElementDefinition.Error.toJson(): DefinitionsJson.Error? { val (name, code) = getValue() if (name.isEmpty()) return null - return ErrorJson(id = id ?: "", name = name, code = code) + return DefinitionsJson.Error(id = id ?: name, name = name, errorCode = code.takeIf { it.isNotEmpty() }) } - private fun EscalationDefinition.toJson(): EscalationJson? { + private fun RootElementDefinition.Escalation.toJson(): DefinitionsJson.Escalation? { val (name, code) = getValue() if (name.isEmpty()) return null - return EscalationJson(id = id ?: "", name = name, code = code) + return DefinitionsJson.Escalation(id = id ?: name, name = name, escalationCode = code.takeIf { it.isNotEmpty() }) } - private fun CompensationDefinition.toJson(): CompensationJson? { - val activityRef = getValue().takeIf { it.isNotEmpty() } ?: return null - return CompensationJson(id = id ?: "", activityRef = activityRef) + private fun Any?.toJsonElement(): JsonElement = when (this) { + null -> JsonNull + is Boolean -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is String -> JsonPrimitive(this) + else -> JsonPrimitive(this.toString()) } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/FlowNodeSorter.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/FlowNodeSorter.kt index f760c418..7999ae4d 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/FlowNodeSorter.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/FlowNodeSorter.kt @@ -1,79 +1,77 @@ package io.miragon.bpmn.adapter.outbound.json -import io.miragon.bpmn.domain.shared.BpmnNodeType import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.SequenceFlowDefinition /** - * Sorts BPMN flow nodes in process-flow order using DFS: + * Sorts the flow nodes of **one BPMN scope** into process-flow order using DFS, so the JSON reads + * top-to-bottom in execution order: * - * - Top-level start events are visited first (alphabetically) - * - Subprocess children are inlined immediately after their parent subprocess + * - Start events are visited first (alphabetically) * - Boundary events are inserted after the node they are attached to, followed by their successors * - Cycles are handled by skipping already-visited nodes * - Any remaining unvisited nodes (e.g. isolated) are appended at the end, sorted alphabetically + * + * Sub-process children are not inlined — they live inside their sub-process node and are sorted by + * applying this sorter to that scope in turn. */ -object FlowNodeSorter { +internal object FlowNodeSorter { @Suppress("CyclomaticComplexMethod") - fun sort(nodes: List): List { - val nodeById = nodes.associateBy { it.id } - val childrenByParent = nodes.filter { it.parentId != null }.groupBy { it.parentId } - val boundaryByAttached = nodes.filter { it.attachedToRef != null }.groupBy { it.attachedToRef } + fun sort( + flowNodes: List, + sequenceFlows: List, + ): List { + val nodeById = flowNodes.associateBy { it.id } + val targetsByFlowId = sequenceFlows.mapNotNull { flow -> flow.id?.let { it to flow.targetRef } }.toMap() + val boundaryByHost = flowNodes + .filterIsInstance() + .filter { it.attachedToRef != null } + .groupBy { it.attachedToRef } val visited = mutableSetOf() val result = mutableListOf() + fun successorsOf(node: FlowNodeDefinition): List { + return node.outgoing + .mapNotNull { targetsByFlowId[it] } + .mapNotNull { nodeById[it] } + .filter { it.id !in visited } + .sortedBy { it.id ?: "" } + } + fun visit(node: FlowNodeDefinition) { if (node.id in visited) return visited.add(node.id) result.add(node) - if (node.nodeType.isSubProcess()) { - val children = childrenByParent[node.id] ?: emptyList() - val childStarts = children - .filter { it.nodeType.isStartEvent() && it.attachedToRef == null && it.previousElements.isEmpty() } - .sortedBy { it.id ?: "" } - childStarts.forEach { visit(it) } - children.filter { it.id !in visited && it.attachedToRef == null } - .sortedBy { it.id ?: "" } - .forEach { visit(it) } - } - - val attached = boundaryByAttached[node.id]?.sortedBy { it.id ?: "" } ?: emptyList() - for (boundary in attached) { + boundaryByHost[node.id]?.sortedBy { it.id ?: "" }?.forEach { boundary -> if (boundary.id !in visited) { visited.add(boundary.id) result.add(boundary) - boundary.followingElements - .mapNotNull { nodeById[it] } - .filter { it.id !in visited } - .sortedBy { it.id ?: "" } - .forEach { visit(it) } + successorsOf(boundary).forEach { visit(it) } } } - node.followingElements - .mapNotNull { nodeById[it] } - .filter { it.id !in visited && it.attachedToRef == null } - .sortedBy { it.id ?: "" } - .forEach { visit(it) } + successorsOf(node).filter { it.isNotBoundaryEvent() }.forEach { visit(it) } } - val topLevel = nodes.filter { it.parentId == null && it.attachedToRef == null } - topLevel.filter { it.nodeType.isStartEvent() && it.previousElements.isEmpty() } + val standalone = flowNodes.filter { it.isNotBoundaryEvent() } + standalone.filter { it.isStartEvent() && it.incoming.isEmpty() } .sortedBy { it.id ?: "" } .forEach { visit(it) } - topLevel.filter { it.id !in visited } + standalone.filter { it.id !in visited } .sortedBy { it.id ?: "" } .forEach { visit(it) } return result } - private fun BpmnNodeType.isSubProcess(): Boolean = - this is BpmnNodeType.Activity.SubProcess && kind == SubProcessKind.PLAIN + private fun FlowNodeDefinition.isNotBoundaryEvent(): Boolean { + return (this as? FlowNodeDefinition.Event)?.shape != EventShape.BOUNDARY_EVENT + } - private fun BpmnNodeType.isStartEvent(): Boolean = - this is BpmnNodeType.Event && shape == EventShape.START_EVENT + private fun FlowNodeDefinition.isStartEvent(): Boolean { + return (this as? FlowNodeDefinition.Event)?.shape == EventShape.START_EVENT + } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/BpmnModelJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/BpmnModelJson.kt deleted file mode 100644 index 5842b81e..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/BpmnModelJson.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.json.model - -import kotlinx.serialization.Serializable - -@Serializable -data class BpmnModelJson( - val processId: String, - val flowNodes: List = emptyList(), - val messages: List = emptyList(), - val signals: List, - val errors: List, - val escalations: List = emptyList(), - val compensations: List = emptyList(), - val sequenceFlows: List = emptyList(), - val variants: List? = null, -) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/CalledElementJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/CalledElementJson.kt new file mode 100644 index 00000000..53d68896 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/CalledElementJson.kt @@ -0,0 +1,13 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.Serializable + +/** + * The target of a `bpmn:CallActivity` plus the engines' variable-propagation flags. + */ +@Serializable +internal data class CalledElementJson( + val processId: String? = null, + val propagateAllInputVariables: Boolean? = null, + val propagateAllOutputVariables: Boolean? = null, +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/CompensationJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/CompensationJson.kt deleted file mode 100644 index 7895f3cf..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/CompensationJson.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.json.model - -import kotlinx.serialization.Serializable - -@Serializable -data class CompensationJson( - val id: String, - val activityRef: String, -) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/DefinitionsJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/DefinitionsJson.kt new file mode 100644 index 00000000..81508ea2 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/DefinitionsJson.kt @@ -0,0 +1,30 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.Serializable + +/** + * The `bpmn:Definitions` root elements referenced by the process, de-duplicated by their own id. + * + * Nodes point here through their `…Ref` members, so a message used by three events is one entry — the + * reason these are entities rather than copies on the node tree (ADR 018). + */ +@Serializable +internal data class DefinitionsJson( + val messages: List = emptyList(), + val signals: List = emptyList(), + val errors: List = emptyList(), + val escalations: List = emptyList(), +) { + + @Serializable + data class Message(val id: String, val name: String, val correlationKey: String? = null) + + @Serializable + data class Signal(val id: String, val name: String) + + @Serializable + data class Error(val id: String, val name: String, val errorCode: String? = null) + + @Serializable + data class Escalation(val id: String, val name: String, val escalationCode: String? = null) +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ErrorJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ErrorJson.kt deleted file mode 100644 index 584c14f9..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ErrorJson.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.json.model - -import kotlinx.serialization.Serializable - -@Serializable -data class ErrorJson( - val id: String, - val name: String, - val code: String, -) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/EscalationJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/EscalationJson.kt deleted file mode 100644 index ae8e719d..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/EscalationJson.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.json.model - -import kotlinx.serialization.Serializable - -@Serializable -data class EscalationJson( - val id: String, - val name: String, - val code: String, -) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/EventDefinitionJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/EventDefinitionJson.kt new file mode 100644 index 00000000..d2eeb919 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/EventDefinitionJson.kt @@ -0,0 +1,53 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator + +/** + * One `bpmn:*EventDefinition` on an event node, discriminated by `type`. BPMN allows several definitions + * on one event, so a node carries a list of these rather than a single kind. + * + * The `…Ref` members point into [DefinitionsJson]; resolve them there for the name and code. + */ +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("type") +internal sealed interface EventDefinitionJson { + + @Serializable + @SerialName("timer") + data class Timer(val timerType: String? = null, val expression: String? = null) : EventDefinitionJson + + @Serializable + @SerialName("message") + data class Message(val messageRef: String? = null) : EventDefinitionJson + + @Serializable + @SerialName("signal") + data class Signal(val signalRef: String? = null) : EventDefinitionJson + + @Serializable + @SerialName("error") + data class Error(val errorRef: String? = null) : EventDefinitionJson + + @Serializable + @SerialName("escalation") + data class Escalation(val escalationRef: String? = null) : EventDefinitionJson + + @Serializable + @SerialName("compensation") + data class Compensation(val activityRef: String? = null, val waitForCompletion: Boolean? = null) : EventDefinitionJson + + @Serializable + @SerialName("conditional") + data class Conditional(val expression: String? = null) : EventDefinitionJson + + @Serializable + @SerialName("link") + data class Link(val linkName: String? = null) : EventDefinitionJson + + @Serializable + @SerialName("terminate") + data object Terminate : EventDefinitionJson +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ExtensionJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ExtensionJson.kt new file mode 100644 index 00000000..6bafab34 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ExtensionJson.kt @@ -0,0 +1,19 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A verbatim projection of one foreign-namespace element below `bpmn:extensionElements`. + * + * [type] keeps the namespace prefix (`zeebe:taskHeaders`, `camunda:properties`) so provenance is never + * lost, and the structure nests arbitrarily. This is the escape hatch for engine data bpmn-to-code does + * not normalise — a new engine feature appears here without a schema change. + */ +@Serializable +internal data class ExtensionJson( + @SerialName("\$type") val type: String, + val attributes: Map = emptyMap(), + val children: List = emptyList(), + val body: String? = null, +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/FlowNodeJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/FlowNodeJson.kt index ff9a7e83..6cf81173 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/FlowNodeJson.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/FlowNodeJson.kt @@ -3,18 +3,41 @@ package io.miragon.bpmn.adapter.outbound.json.model import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +/** + * One BPMN flow node. + * + * [type] is the BPMN element's own name (`serviceTask`, `startEvent`, `subProcess`, …), so the object maps + * onto `bpmn-moddle` by prefixing it with `bpmn:`. [incoming] and [outgoing] hold **sequence-flow ids**, + * matching `bpmn:FlowNode.incoming` / `.outgoing`. + * + * The optional members are independent facets, each present only where BPMN allows it: containment + * ([flowNodes] / [sequenceFlows]) on sub-processes, [eventDefinitions] on events, [multiInstance] and + * [ioMapping] on activities, and so on. [extensions] and [engineAttributes] carry everything the engine + * adds outside the BPMN namespace, verbatim. + */ @Serializable -data class FlowNodeJson( +internal data class FlowNodeJson( val id: String, - val displayName: String? = null, - val elementType: String, - val parentId: String? = null, + val type: String, + val name: String? = null, + val incoming: List = emptyList(), + val outgoing: List = emptyList(), + val default: String? = null, val attachedToRef: String? = null, - val interrupting: Boolean? = null, - val attachedElements: List = emptyList(), - val previousElements: List = emptyList(), - val followingElements: List = emptyList(), - val variables: List = emptyList(), - val properties: FlowNodePropertiesJson? = null, - val engineSpecificProperties: Map = emptyMap(), + val cancelActivity: Boolean? = null, + val isInterrupting: Boolean? = null, + val triggeredByEvent: Boolean? = null, + val isForCompensation: Boolean? = null, + val boundaryEventRefs: List = emptyList(), + val eventDefinitions: List = emptyList(), + val messageRef: String? = null, + val implementation: ImplementationJson? = null, + val calledElement: CalledElementJson? = null, + val multiInstance: MultiInstanceJson? = null, + val ioMapping: IoMappingJson? = null, + val variables: List = emptyList(), + val flowNodes: List = emptyList(), + val sequenceFlows: List = emptyList(), + val extensions: List = emptyList(), + val engineAttributes: Map = emptyMap(), ) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/FlowNodePropertiesJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/FlowNodePropertiesJson.kt deleted file mode 100644 index 788aa2ba..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/FlowNodePropertiesJson.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.json.model - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -@Serializable -data class FlowNodePropertiesJson( - val type: String, - val implementationValue: String? = null, - val implementationKind: String? = null, - val calledElement: String? = null, - val timerType: String? = null, - val timerValue: String? = null, - val messageName: String? = null, - val messageDirection: String? = null, - val signalName: String? = null, - val signalDirection: String? = null, - val engineSpecificProperties: Map = emptyMap(), -) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ImplementationJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ImplementationJson.kt new file mode 100644 index 00000000..62883f2f --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ImplementationJson.kt @@ -0,0 +1,39 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator + +/** + * How a service-task-like node is implemented, normalised across engines and discriminated by `type`. + * Absent when the node declares no implementation at all. + */ +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("type") +internal sealed interface ImplementationJson { + + @Serializable + @SerialName("jobWorker") + data class JobWorker(val jobType: String, val retries: String? = null) : ImplementationJson + + @Serializable + @SerialName("connector") + data class Connector(val jobType: String, val templateId: String? = null, val retries: String? = null) : ImplementationJson + + @Serializable + @SerialName("externalTask") + data class ExternalTask(val topic: String) : ImplementationJson + + @Serializable + @SerialName("javaClass") + data class JavaClass(val className: String) : ImplementationJson + + @Serializable + @SerialName("delegateExpression") + data class DelegateExpression(val expression: String) : ImplementationJson + + @Serializable + @SerialName("expression") + data class Expression(val expression: String) : ImplementationJson +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/IoMappingJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/IoMappingJson.kt new file mode 100644 index 00000000..a4137ee2 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/IoMappingJson.kt @@ -0,0 +1,23 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.Serializable + +/** + * A node's input/output parameter mapping ([#74](https://github.com/Miragon/bpmn-to-code/issues/74)): + * `zeebe:ioMapping` and `camunda:inputOutput` both normalise onto this shape. + */ +@Serializable +internal data class IoMappingJson( + val inputs: List = emptyList(), + val outputs: List = emptyList(), +) { + + /** + * [target] is the variable being written, [source] the expression or static value bound to it. + */ + @Serializable + data class Parameter( + val target: String, + val source: String? = null, + ) +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/MessageJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/MessageJson.kt deleted file mode 100644 index 86f819a0..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/MessageJson.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.json.model - -import kotlinx.serialization.Serializable - -@Serializable -data class MessageJson( - val id: String, - val name: String, -) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/MultiInstanceJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/MultiInstanceJson.kt new file mode 100644 index 00000000..a5d91a1d --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/MultiInstanceJson.kt @@ -0,0 +1,19 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.Serializable + +/** + * `bpmn:multiInstanceLoopCharacteristics` on an activity, normalised across engines + * ([#73](https://github.com/Miragon/bpmn-to-code/issues/73)). The expressions are preserved verbatim, so + * they stay in the engine's own syntax. + */ +@Serializable +internal data class MultiInstanceJson( + val sequential: Boolean, + val inputCollection: String? = null, + val inputElement: String? = null, + val outputCollection: String? = null, + val outputElement: String? = null, + val cardinality: String? = null, + val completionCondition: String? = null, +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ProcessJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ProcessJson.kt new file mode 100644 index 00000000..57888916 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ProcessJson.kt @@ -0,0 +1,16 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.Serializable + +/** + * The `bpmn:Process` scope: its metadata plus the flow nodes and sequence flows it directly contains. + */ +@Serializable +internal data class ProcessJson( + val id: String, + val name: String? = null, + val isExecutable: Boolean = true, + val engine: String? = null, + val flowNodes: List = emptyList(), + val sequenceFlows: List = emptyList(), +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ProcessModelJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ProcessModelJson.kt new file mode 100644 index 00000000..2511f6e2 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/ProcessModelJson.kt @@ -0,0 +1,28 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Root of the generated process JSON (format 2.0). + * + * The shape is aligned with OMG BPMN 2.0 / `bpmn-moddle`: a scope owns its flow nodes **and** its sequence + * flows, a node references flows by id, and `bpmn:Definitions` root elements live in a shared registry. + * See [ADR 018](../../../../../../../../../docs/contributing/adr/018-process-json-v2.md). + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +internal data class ProcessModelJson( + @EncodeDefault(EncodeDefault.Mode.ALWAYS) @SerialName("\$schema") val schema: String = SCHEMA_URL, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val formatVersion: String = FORMAT_VERSION, + val process: ProcessJson, + val definitions: DefinitionsJson = DefinitionsJson(), + val variants: List? = null, +) { + companion object { + const val FORMAT_VERSION = "2.0" + const val SCHEMA_URL = "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json" + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/SequenceFlowJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/SequenceFlowJson.kt index e7b6df8a..aa520af7 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/SequenceFlowJson.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/SequenceFlowJson.kt @@ -2,12 +2,14 @@ package io.miragon.bpmn.adapter.outbound.json.model import kotlinx.serialization.Serializable +/** + * A `bpmn:SequenceFlow`, always emitted inside the scope that owns it. + */ @Serializable -data class SequenceFlowJson( +internal data class SequenceFlowJson( val id: String, val sourceRef: String, val targetRef: String, val name: String? = null, val conditionExpression: String? = null, - val isDefault: Boolean, ) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/SignalJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/SignalJson.kt deleted file mode 100644 index 25aa3d17..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/SignalJson.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.json.model - -import kotlinx.serialization.Serializable - -@Serializable -data class SignalJson( - val id: String, - val name: String, -) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/VariableJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/VariableJson.kt new file mode 100644 index 00000000..505b3fec --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/VariableJson.kt @@ -0,0 +1,13 @@ +package io.miragon.bpmn.adapter.outbound.json.model + +import kotlinx.serialization.Serializable + +/** + * A process variable a node reads or writes, with the direction and expression from ADR 015. + */ +@Serializable +internal data class VariableJson( + val name: String, + val direction: String, + val expression: String? = null, +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/VariantJson.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/VariantJson.kt index 316c6922..ec6ab277 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/VariantJson.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/json/model/VariantJson.kt @@ -2,9 +2,12 @@ package io.miragon.bpmn.adapter.outbound.json.model import kotlinx.serialization.Serializable +/** + * One process variant of a merged model — the same process id modelled in several BPMN files. + */ @Serializable -data class VariantJson( - val variantName: String, - val flowNodes: List, - val sequenceFlows: List, +internal data class VariantJson( + val name: String, + val flowNodes: List = emptyList(), + val sequenceFlows: List = emptyList(), ) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/shared/BpmnTypeName.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/shared/BpmnTypeName.kt new file mode 100644 index 00000000..e5a80139 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/shared/BpmnTypeName.kt @@ -0,0 +1,62 @@ +package io.miragon.bpmn.adapter.outbound.shared + +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.TaskKind + +/** + * Renders a [FlowNodeDefinition] into its **BPMN element name** (`serviceTask`, `startEvent`, …) for the + * JSON export. Prefixing the result with `bpmn:` yields the `$type` a `bpmn-moddle` consumer expects. + * + * An event's trigger is *not* folded into this name — it lives in `eventDefinitions`, because BPMN allows + * several triggers on one event. The generated Process API keeps the flattened vocabulary instead, see + * [ElementTypeName]. + */ +internal object BpmnTypeName { + + fun of(node: FlowNodeDefinition): String = when (node) { + is FlowNodeDefinition.Gateway -> node.kind.render() + is FlowNodeDefinition.Event -> node.shape.render() + is FlowNodeDefinition.Activity.Task -> node.kind.render() + is FlowNodeDefinition.Activity.SubProcess -> node.kind.render() + is FlowNodeDefinition.Activity.CallActivity -> "callActivity" + is FlowNodeDefinition.Unknown -> "unknown" + } + + private fun TaskKind.render(): String = when (this) { + TaskKind.SERVICE -> "serviceTask" + TaskKind.USER -> "userTask" + TaskKind.RECEIVE -> "receiveTask" + TaskKind.SEND -> "sendTask" + TaskKind.SCRIPT -> "scriptTask" + TaskKind.MANUAL -> "manualTask" + TaskKind.BUSINESS_RULE -> "businessRuleTask" + TaskKind.NONE -> "task" + } + + private fun GatewayKind.render(): String = when (this) { + GatewayKind.EXCLUSIVE -> "exclusiveGateway" + GatewayKind.PARALLEL -> "parallelGateway" + GatewayKind.INCLUSIVE -> "inclusiveGateway" + GatewayKind.EVENT_BASED -> "eventBasedGateway" + GatewayKind.COMPLEX -> "complexGateway" + } + + private fun EventShape.render(): String = when (this) { + EventShape.START_EVENT -> "startEvent" + EventShape.END_EVENT -> "endEvent" + EventShape.INTERMEDIATE_CATCH_EVENT -> "intermediateCatchEvent" + EventShape.INTERMEDIATE_THROW_EVENT -> "intermediateThrowEvent" + EventShape.BOUNDARY_EVENT -> "boundaryEvent" + } + + /** + * Event sub-processes keep the `subProcess` name and are marked by `triggeredByEvent`, as in BPMN. + */ + private fun SubProcessKind.render(): String = when (this) { + SubProcessKind.PLAIN, SubProcessKind.EVENT -> "subProcess" + SubProcessKind.TRANSACTION -> "transaction" + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/shared/ElementTypeName.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/shared/ElementTypeName.kt index a2a8b305..fe92ec47 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/shared/ElementTypeName.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/adapter/outbound/shared/ElementTypeName.kt @@ -1,39 +1,49 @@ package io.miragon.bpmn.adapter.outbound.shared -import io.miragon.bpmn.domain.shared.BpmnNodeType -import io.miragon.bpmn.domain.shared.EventDefinitionType +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.FlowNodeDefinition import io.miragon.bpmn.domain.shared.GatewayKind import io.miragon.bpmn.domain.shared.SubProcessKind import io.miragon.bpmn.domain.shared.TaskKind /** - * Renders the two-axis [BpmnNodeType] domain model into the flat `elementType` string shared by the - * outbound representations — the generated JSON export and the generated Process API. + * Renders a [FlowNodeDefinition] into the flat `elementType` string used by the **generated Process API** + * (`BpmnRelations.elementType`). * - * This is the single boundary between the internal node-type model and that output vocabulary. - * Tasks, gateways and activities map to their flat name; event nodes surface their concrete - * [definitionType][BpmnNodeType.Event.definitionType] as a prefix on the shape - * (e.g. `ERROR_BOUNDARY_EVENT`), so consumers can tell a timer from an error without cross-referencing. + * Tasks, gateways and activities map to their flat name; an event surfaces its first event definition as a + * prefix on the shape (e.g. `ERROR_BOUNDARY_EVENT`), so consumers can tell a timer from an error without + * cross-referencing. The JSON export uses the BPMN element names instead — see `BpmnTypeName`. */ -object ElementTypeName { - - fun of(nodeType: BpmnNodeType): String = when (nodeType) { - is BpmnNodeType.Gateway -> nodeType.kind.render() - is BpmnNodeType.Event -> nodeType.render() - is BpmnNodeType.Activity.Task -> nodeType.kind.render() - is BpmnNodeType.Activity.SubProcess -> nodeType.kind.render() - is BpmnNodeType.Activity.CallActivity -> "CALL_ACTIVITY" - is BpmnNodeType.Unknown -> "UNKNOWN" +internal object ElementTypeName { + + fun of(node: FlowNodeDefinition): String = when (node) { + is FlowNodeDefinition.Gateway -> node.kind.render() + is FlowNodeDefinition.Event -> node.render() + is FlowNodeDefinition.Activity.Task -> node.kind.render() + is FlowNodeDefinition.Activity.SubProcess -> node.kind.render() + is FlowNodeDefinition.Activity.CallActivity -> "CALL_ACTIVITY" + is FlowNodeDefinition.Unknown -> "UNKNOWN" } - private fun BpmnNodeType.Event.render(): String { - return if (definitionType == EventDefinitionType.NONE) { - shape.name - } else { - "${definitionType.name}_${shape.name}" - } + private fun FlowNodeDefinition.Event.render(): String { + val definitionType = eventDefinitions.map { it.type }.firstOrNull { it in PREFIXED_TYPES } + ?: return shape.name + return "${definitionType.name}_${shape.name}" } + /** + * The event-definition kinds that surface as a prefix on the flat element type. Conditional, link and + * terminate carry no prefix — the flat Process API vocabulary renders them shape-only, e.g. `END_EVENT`. + */ + private val PREFIXED_TYPES = setOf( + EventDefinitionInstance.Type.TIMER, + EventDefinitionInstance.Type.MESSAGE, + EventDefinitionInstance.Type.ERROR, + EventDefinitionInstance.Type.SIGNAL, + EventDefinitionInstance.Type.ESCALATION, + EventDefinitionInstance.Type.COMPENSATION, + ) + private fun TaskKind.render(): String = when (this) { TaskKind.SERVICE -> "SERVICE_TASK" TaskKind.USER -> "USER_TASK" diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/inbound/ExtractProcessModelsUseCase.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/inbound/ExtractProcessModelsUseCase.kt new file mode 100644 index 00000000..5c228620 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/inbound/ExtractProcessModelsUseCase.kt @@ -0,0 +1,22 @@ +package io.miragon.bpmn.application.port.inbound + +import io.miragon.bpmn.domain.BpmnResource +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.shared.ProcessEngine + +/** + * Turns raw BPMN resources into process models, without generating or validating anything. + * + * Callers that bring their own resources and drive validation themselves — `bpmn-to-code-testing` does — + * need the models and nothing else. Without this port their only route was the engine adapter directly, + * which put a second module inside core's outbound internals. + */ +interface ExtractProcessModelsUseCase { + + fun extractProcessModels(command: Command): List + + data class Command( + val resources: List, + val engine: ProcessEngine, + ) +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/inbound/ValidateBpmnFromFilesystemUseCase.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/inbound/ValidateBpmnFromFilesystemUseCase.kt index caa867f7..23aa7691 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/inbound/ValidateBpmnFromFilesystemUseCase.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/inbound/ValidateBpmnFromFilesystemUseCase.kt @@ -1,8 +1,8 @@ package io.miragon.bpmn.application.port.inbound import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.validation.model.ValidationConfig import io.miragon.bpmn.domain.validation.ValidationResult +import io.miragon.bpmn.domain.validation.model.ValidationConfig interface ValidateBpmnFromFilesystemUseCase { diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/outbound/ExtractBpmnPort.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/outbound/ExtractBpmnPort.kt index 9a916c51..d492e83f 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/outbound/ExtractBpmnPort.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/port/outbound/ExtractBpmnPort.kt @@ -1,9 +1,9 @@ package io.miragon.bpmn.application.port.outbound -import io.miragon.bpmn.domain.BpmnModel import io.miragon.bpmn.domain.BpmnResource +import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.ProcessEngine interface ExtractBpmnPort { - fun extract(bpmnFile: BpmnResource, engine: ProcessEngine): BpmnModel + fun extract(bpmnFile: BpmnResource, engine: ProcessEngine): ProcessModel } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/ExtractProcessModelsService.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/ExtractProcessModelsService.kt new file mode 100644 index 00000000..38599ddf --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/ExtractProcessModelsService.kt @@ -0,0 +1,15 @@ +package io.miragon.bpmn.application.service + +import io.miragon.bpmn.adapter.outbound.engine.ExtractBpmnAdapter +import io.miragon.bpmn.application.port.inbound.ExtractProcessModelsUseCase +import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort +import io.miragon.bpmn.domain.ProcessModel + +class ExtractProcessModelsService( + private val bpmnService: ExtractBpmnPort = ExtractBpmnAdapter(), +) : ExtractProcessModelsUseCase { + + override fun extractProcessModels(command: ExtractProcessModelsUseCase.Command): List { + return command.resources.map { bpmnService.extract(it, command.engine) } + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiInMemoryService.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiInMemoryService.kt index df861af0..ff744615 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiInMemoryService.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiInMemoryService.kt @@ -41,7 +41,7 @@ class GenerateProcessApiInMemoryService( model = model, outputLanguage = command.outputLanguage, packagePath = command.packagePath, - engine = command.engine, + targetEngine = command.engine, ) private fun toBpmnFiles( diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiService.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiService.kt index cc033e6a..53d40db2 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiService.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiService.kt @@ -1,23 +1,22 @@ package io.miragon.bpmn.application.service +import io.github.oshai.kotlinlogging.KotlinLogging import io.miragon.bpmn.adapter.outbound.codegen.CodeGenerationAdapter import io.miragon.bpmn.adapter.outbound.engine.ExtractBpmnAdapter import io.miragon.bpmn.adapter.outbound.filesystem.BpmnFileLoader import io.miragon.bpmn.adapter.outbound.filesystem.ProcessApiFileSaver import io.miragon.bpmn.application.port.inbound.GenerateProcessApiFromFilesystemUseCase -import io.miragon.bpmn.domain.BpmnFileResult import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort import io.miragon.bpmn.application.port.outbound.GenerateApiCodePort import io.miragon.bpmn.application.port.outbound.LoadBpmnFilesPort import io.miragon.bpmn.application.port.outbound.SaveProcessApiPort -import io.miragon.bpmn.domain.BpmnModel +import io.miragon.bpmn.domain.BpmnFileResult import io.miragon.bpmn.domain.BpmnModelApi import io.miragon.bpmn.domain.BpmnResource import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.service.BpmnValidationService import io.miragon.bpmn.domain.service.ModelMergerService import io.miragon.bpmn.domain.validation.model.ValidationPhase -import io.github.oshai.kotlinlogging.KotlinLogging class GenerateProcessApiService( private val codeGenerator: GenerateApiCodePort = CodeGenerationAdapter(), @@ -53,8 +52,8 @@ class GenerateProcessApiService( } private fun filterExecutableProcesses( - extractedModels: List>, - ): List> { + extractedModels: List>, + ): List> { return extractedModels.filter { (file, model) -> val keep = model.isExecutable if (!keep) logger.info { "Skipping '${model.processId}' (${file.fileName}): process is marked non-executable" } @@ -69,7 +68,7 @@ class GenerateProcessApiService( model = model, outputLanguage = command.outputLanguage, packagePath = command.packagePath, - engine = command.engine, + targetEngine = command.engine, ) } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/ValidateBpmnService.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/ValidateBpmnService.kt index a7de8ccc..6eeae38f 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/ValidateBpmnService.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/application/service/ValidateBpmnService.kt @@ -7,9 +7,9 @@ import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort import io.miragon.bpmn.application.port.outbound.LoadBpmnFilesPort import io.miragon.bpmn.domain.service.BpmnValidationService import io.miragon.bpmn.domain.service.ModelMergerService +import io.miragon.bpmn.domain.validation.ValidationResult import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.ValidationPhase -import io.miragon.bpmn.domain.validation.ValidationResult class ValidateBpmnService( private val bpmnFileLoader: LoadBpmnFilesPort = BpmnFileLoader(), diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/BpmnModel.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/BpmnModel.kt deleted file mode 100644 index 30738b6c..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/BpmnModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.miragon.bpmn.domain - -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.ErrorDefinition -import io.miragon.bpmn.domain.shared.EscalationDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition -import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.SignalDefinition -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition - -data class BpmnModel( - override val processId: String, - val variantName: String? = null, - override val flowNodes: List, - override val sequenceFlows: List = emptyList(), - override val messages: List, - override val signals: List, - override val errors: List, - override val escalations: List = emptyList(), - override val compensations: List = emptyList(), - val detectedEngine: ProcessEngine? = null, - val isExecutable: Boolean = true, -) : ProcessModel { - override val serviceTasks: List - get() = flowNodes.mapNotNull { (it.properties as? FlowNodeProperties.ServiceTask)?.definition } - .distinctBy { it.getRawName() } - .sortedBy { it.getRawName() } - - override val callActivities: List - get() = flowNodes.mapNotNull { (it.properties as? FlowNodeProperties.CallActivity)?.definition } - .sortedBy { it.getRawName() } - - override val timers: List - get() = flowNodes.mapNotNull { (it.properties as? FlowNodeProperties.Timer)?.definition } - .sortedBy { it.getRawName() } - - override val variables: List - get() = flowNodes.flatMap { it.variables }.distinct() - .sortedBy { it.getRawName() } -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/BpmnModelApi.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/BpmnModelApi.kt index 0447931e..fa0accc4 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/BpmnModelApi.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/BpmnModelApi.kt @@ -3,11 +3,18 @@ package io.miragon.bpmn.domain import io.miragon.bpmn.domain.shared.OutputLanguage import io.miragon.bpmn.domain.shared.ProcessEngine +/** + * A [ProcessModel] together with the code-generation settings it is rendered with. + * + * [targetEngine] is the engine the caller asked to generate for. It is deliberately *not* the same as + * [ProcessModel.detectedEngine], which is what the BPMN file itself declares — comparing the two is what + * `EngineMismatchRule` does. + */ data class BpmnModelApi( val model: ProcessModel, val outputLanguage: OutputLanguage, val packagePath: String, - val engine: ProcessEngine, + val targetEngine: ProcessEngine, ) { fun fileName(): String { diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/MergedBpmnModel.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/MergedBpmnModel.kt deleted file mode 100644 index 1e104db7..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/MergedBpmnModel.kt +++ /dev/null @@ -1,52 +0,0 @@ -package io.miragon.bpmn.domain - -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.ErrorDefinition -import io.miragon.bpmn.domain.shared.EscalationDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition -import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.SignalDefinition -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition - -data class MergedBpmnModel( - override val processId: String, - override val flowNodes: List, - override val messages: List, - override val signals: List, - override val errors: List, - override val escalations: List = emptyList(), - override val compensations: List = emptyList(), - val variants: List = emptyList(), -) : ProcessModel { - - override val sequenceFlows: List - get() = emptyList() - - override val serviceTasks: List - get() = flowNodes.mapNotNull { (it.properties as? FlowNodeProperties.ServiceTask)?.definition } - .distinctBy { it.getRawName() } - .sortedBy { it.getRawName() } - - override val callActivities: List - get() = flowNodes.mapNotNull { (it.properties as? FlowNodeProperties.CallActivity)?.definition } - .sortedBy { it.getRawName() } - - override val timers: List - get() = flowNodes.mapNotNull { (it.properties as? FlowNodeProperties.Timer)?.definition } - .sortedBy { it.getRawName() } - - override val variables: List - get() = flowNodes.flatMap { it.variables }.distinct() - .sortedBy { it.getRawName() } - - data class VariantData( - val variantName: String, - val sequenceFlows: List, - val flowNodes: List, - ) -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/NamedEventUsage.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/NamedEventUsage.kt new file mode 100644 index 00000000..094de098 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/NamedEventUsage.kt @@ -0,0 +1,17 @@ +package io.miragon.bpmn.domain + +import io.miragon.bpmn.domain.shared.EventDirection +import io.miragon.bpmn.domain.shared.FlowNodeDefinition + +/** + * One node's use of a named `bpmn:Definitions` root element — a message or a signal — together with the + * throw/catch role it plays. Correlation rules reason over these rather than over node types, so a + * message catch event and a receive task are treated uniformly. + * + * Produced by [ProcessModel.messageUsages] and [ProcessModel.signalUsages]. + */ +data class NamedEventUsage( + val node: FlowNodeDefinition, + val name: String, + val direction: EventDirection, +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/ProcessModel.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/ProcessModel.kt index af7149fa..2fa168a2 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/ProcessModel.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/ProcessModel.kt @@ -2,27 +2,187 @@ package io.miragon.bpmn.domain import io.miragon.bpmn.domain.shared.CallActivityDefinition import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.ErrorDefinition -import io.miragon.bpmn.domain.shared.EscalationDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventDirection +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.MessageDefinition +import io.miragon.bpmn.domain.shared.ProcessEngine +import io.miragon.bpmn.domain.shared.ProcessGraph +import io.miragon.bpmn.domain.shared.RootElements import io.miragon.bpmn.domain.shared.SequenceFlowDefinition import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.SignalDefinition +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.TaskKind import io.miragon.bpmn.domain.shared.TimerDefinition import io.miragon.bpmn.domain.shared.VariableDefinition -sealed interface ProcessModel { - val processId: String - val flowNodes: List - val sequenceFlows: List - val messages: List - val signals: List - val errors: List - val escalations: List - val compensations: List +/** + * A process as bpmn-to-code understands it — whether it came from one BPMN file or from several that + * declare the same process id. + * + * [flowNodes] and [sequenceFlows] are the **root scope's** content; a sub-process owns its own children + * and flows (see [FlowNodeDefinition.Activity.SubProcess]). Consumers that need a flat view use [graph]. + * + * [definitions] holds the `bpmn:Definitions` root-element registries that nodes reference. Everything + * else — timers, compensations, service-task implementations, call activities and variables — is *derived* + * from the node tree, so it can never drift from it. + * + * [variants] is empty for a single-file process and holds the per-variant node sets once several files + * have been merged; [flowNodes] then carries their union, so a consumer that ignores variants still sees + * a complete process. See [ADR 017](../../../../../../../docs/contributing/adr/017-bpmn-aligned-domain-model.md). + */ +@Suppress("TooManyFunctions") +data class ProcessModel( + val processId: String, + val processName: String? = null, + val flowNodes: List, + val sequenceFlows: List = emptyList(), + val definitions: RootElements = RootElements(), + val isExecutable: Boolean = true, + val detectedEngine: ProcessEngine? = null, + val variantName: String? = null, + val variants: List = emptyList(), +) { + + val graph: ProcessGraph by lazy { ProcessGraph(flowNodes, sequenceFlows) } + + val allFlowNodes: List get() = graph.allFlowNodes + + /** + * True once several BPMN files declaring this process id have been merged into one model. + */ + val isMerged: Boolean get() = variants.isNotEmpty() + + /** + * One entry per service-task-like node, keyed by the node — two tasks sharing a job type stay two + * tasks here, so validation can name each of them. The generated API collapses them to one constant. + */ val serviceTasks: List + get() = allFlowNodes + .mapNotNull { node -> node.taskImplementation()?.let { ServiceTaskDefinition(node.id, it) } } + .distinctBy { it.id to it.getRawName() } + .sortedBy { it.getRawName() } + val callActivities: List + get() = allFlowNodes + .filterIsInstance() + .map { it.definition } + .sortedBy { it.getRawName() } + val timers: List + get() = allFlowNodes + .filterIsInstance() + .flatMap { node -> node.eventDefinitions.filterIsInstance().map { node to it } } + .map { (node, timer) -> TimerDefinition(node.id, timer.timerType, timer.expression) } + .sortedBy { it.getRawName() } + + val compensations: List + get() = allFlowNodes + .filterIsInstance() + .flatMap { node -> node.eventDefinitions.filterIsInstance().map { node to it } } + .map { (node, compensation) -> compensation.toDefinition(node) } + .filter { it.getRawName().isNotEmpty() } + .distinctBy { it.getRawName() } + .sortedBy { it.getRawName() } + val variables: List + get() = allFlowNodes.flatMap { it.variables }.distinct().sortedBy { it.getRawName() } + + /** + * Every message reference in the process: message events plus send and receive tasks. + */ + fun messageUsages(): List { + val fromEvents = allFlowNodes + .filterIsInstance() + .flatMap { node -> + node.eventDefinitions + .filterIsInstance() + .mapNotNull { it.reference.messageName?.let { name -> NamedEventUsage(node, name, node.shape.direction) } } + } + val fromTasks = allFlowNodes + .filterIsInstance() + .mapNotNull { node -> + val name = node.message?.messageName ?: return@mapNotNull null + val direction = if (node.kind == TaskKind.SEND) EventDirection.THROW else EventDirection.CATCH + NamedEventUsage(node, name, direction) + } + return fromEvents + fromTasks + } + + /** + * Every signal reference in the process. + */ + fun signalUsages(): List { + return allFlowNodes + .filterIsInstance() + .flatMap { node -> + node.eventDefinitions + .filterIsInstance() + .mapNotNull { it.signalName?.let { name -> NamedEventUsage(node, name, node.shape.direction) } } + } + } + + /** + * Every error reference in the process, paired with the event that declares it. + */ + fun errorUsages(): List> { + return allFlowNodes + .filterIsInstance() + .flatMap { node -> node.eventDefinitions.filterIsInstance().map { node to it } } + } + + /** + * Ids of the `bpmn:Definitions` root elements the nodes actually point at. + * + * A file may declare more than these — `UnreferencedRootElementRule` reports the difference. + */ + fun referencedDefinitionIds(): Set { + return allFlowNodes.flatMapTo(mutableSetOf()) { node -> + when (node) { + is FlowNodeDefinition.Event -> node.eventDefinitions.mapNotNull { it.referencedId() } + is FlowNodeDefinition.Activity.Task -> listOfNotNull(node.message?.messageRef) + else -> emptyList() + } + } + } + + private fun EventDefinitionInstance.referencedId(): String? = when (this) { + is EventDefinitionInstance.Message -> reference.messageRef + is EventDefinitionInstance.Signal -> signalRef + is EventDefinitionInstance.Error -> errorRef + is EventDefinitionInstance.Escalation -> escalationRef + else -> null + } + + /** + * The service-task-like implementation of a node, if the engine dialect resolved one. + */ + private fun FlowNodeDefinition.taskImplementation(): TaskImplementation? = when (this) { + is FlowNodeDefinition.Activity.Task -> implementation + is FlowNodeDefinition.Event -> implementation + else -> null + } + + private fun EventDefinitionInstance.Compensation.toDefinition( + node: FlowNodeDefinition.Event, + ): CompensationDefinition { + val type = if (node.shape == EventShape.BOUNDARY_EVENT) CompensationDefinition.Type.CATCHING else CompensationDefinition.Type.THROWING + return CompensationDefinition( + id = node.id, + type = type, + activityRef = activityRef, + waitForCompletion = waitForCompletion, + ) + } + + /** + * One merged-in BPMN file: the same process id, modelled differently. + */ + data class Variant( + val variantName: String, + val flowNodes: List = emptyList(), + val sequenceFlows: List = emptyList(), + ) { + val graph: ProcessGraph by lazy { ProcessGraph(flowNodes, sequenceFlows) } + } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/BpmnValidationService.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/BpmnValidationService.kt index 68d17e35..38236562 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/BpmnValidationService.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/BpmnValidationService.kt @@ -1,11 +1,12 @@ package io.miragon.bpmn.domain.service +import io.github.oshai.kotlinlogging.KotlinLogging import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.ProcessEngine import io.miragon.bpmn.domain.validation.BpmnValidationException import io.miragon.bpmn.domain.validation.model.Severity -import io.miragon.bpmn.domain.validation.model.ValidationConfig import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext +import io.miragon.bpmn.domain.validation.model.ValidationConfig import io.miragon.bpmn.domain.validation.model.ValidationPhase import io.miragon.bpmn.domain.validation.model.ValidationViolation import io.miragon.bpmn.domain.validation.rules.CollisionDetectionRule @@ -14,12 +15,11 @@ import io.miragon.bpmn.domain.validation.rules.EngineMismatchRule import io.miragon.bpmn.domain.validation.rules.MissingCalledElementRule import io.miragon.bpmn.domain.validation.rules.MissingElementIdRule import io.miragon.bpmn.domain.validation.rules.MissingErrorDefinitionRule -import io.miragon.bpmn.domain.validation.rules.MissingServiceTaskImplementationRule import io.miragon.bpmn.domain.validation.rules.MissingMessageNameRule import io.miragon.bpmn.domain.validation.rules.MissingProcessIdRule +import io.miragon.bpmn.domain.validation.rules.MissingServiceTaskImplementationRule import io.miragon.bpmn.domain.validation.rules.MissingSignalNameRule import io.miragon.bpmn.domain.validation.rules.MissingTimerDefinitionRule -import io.github.oshai.kotlinlogging.KotlinLogging class BpmnValidationService( private val config: ValidationConfig = ValidationConfig(), diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/CollisionDetectionService.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/CollisionDetectionService.kt index 9fac2884..9ad57e56 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/CollisionDetectionService.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/CollisionDetectionService.kt @@ -20,14 +20,14 @@ class CollisionDetectionService { fun findCollisions(model: ProcessModel): List { val modelId = model.processId val collisions = mutableListOf() - collisions.addAll(findCollisionsIn(modelId, model.flowNodes, "FlowNode")) + collisions.addAll(findCollisionsIn(modelId, model.allFlowNodes, "FlowNode")) collisions.addAll(findCollisionsIn(modelId, model.serviceTasks, "ServiceTask")) - collisions.addAll(findCollisionsIn(modelId, model.messages, "Message")) - collisions.addAll(findCollisionsIn(modelId, model.signals, "Signal")) - collisions.addAll(findCollisionsIn(modelId, model.errors, "Error")) + collisions.addAll(findCollisionsIn(modelId, model.definitions.messages, "Message")) + collisions.addAll(findCollisionsIn(modelId, model.definitions.signals, "Signal")) + collisions.addAll(findCollisionsIn(modelId, model.definitions.errors, "Error")) collisions.addAll(findCollisionsIn(modelId, model.timers, "Timer")) collisions.addAll(findCollisionsIn(modelId, model.variables, "Variable")) - collisions.addAll(findCollisionsIn(modelId, model.flowNodes, "FlowNode") { it.getRawName().toCamelCase() }) + collisions.addAll(findCollisionsIn(modelId, model.allFlowNodes, "FlowNode") { it.getRawName().toCamelCase() }) return collisions.distinctBy { Triple(it.processId, it.variableType, it.conflictingIds) } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/ModelMergerService.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/ModelMergerService.kt index bdc4ab01..cd50eaaf 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/ModelMergerService.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/service/ModelMergerService.kt @@ -1,126 +1,126 @@ package io.miragon.bpmn.domain.service -import io.miragon.bpmn.domain.BpmnModel -import io.miragon.bpmn.domain.MergedBpmnModel import io.miragon.bpmn.domain.ProcessModel -import io.miragon.bpmn.domain.MergedBpmnModel.VariantData +import io.miragon.bpmn.domain.ProcessModel.Variant import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.VariableMapping +import io.miragon.bpmn.domain.shared.FlowScope class ModelMergerService { /** - * Merges BPMN models by process ID. - * Single-model processes are returned as-is. - * Multi-model processes are merged into a [MergedBpmnModel] with variant-scoped flows/relations. + * Merges process models by process id. + * A process backed by a single BPMN file keeps an empty [ProcessModel.variants]. + * A process backed by several files gains one [Variant] per file, with [ProcessModel.flowNodes] + * holding their union. */ - fun mergeModels(models: List): List { - val modelsPerProcess = models.groupBy { it.processId } - return modelsPerProcess.entries - .sortedBy { it.key } - .map { (processId, modelList) -> - if (modelList.size == 1) { - deduplicateSingleModel(modelList.first()).sortContent() - } else { - mergeModelsWithSameProcessId(processId, modelList).sortContent() - } - } + fun mergeModels(models: List): List { + val groupedModels = models.groupBy { it.processId }.entries.sortedBy { it.key } + return groupedModels.map { (processId, modelsOfProcess) -> merge(processId, modelsOfProcess).sortContent() } } - private fun deduplicateSingleModel(model: BpmnModel): BpmnModel { - val models = listOf(model) - return model.copy( - flowNodes = mergeFlowNodes(models), - messages = mergeDistinctBy(models) { it.messages }, - signals = mergeDistinctBy(models) { it.signals }, - errors = mergeDistinctBy(models) { it.errors }, - escalations = mergeDistinctBy(models) { it.escalations }, - compensations = mergeDistinctBy(models) { it.compensations }, + private fun merge(processId: String, models: List): ProcessModel { + if (models.size == 1) return models.first().deduplicated() + requireVariantNames(processId, models) + val sorted = models.sortedBy { requireNotNull(it.variantName) } + val merged = mergeScopes(sorted.map { it.scope() }) + return ProcessModel( + processId = processId, + processName = sorted.firstNotNullOfOrNull { it.processName }, + flowNodes = merged.flowNodes, + sequenceFlows = merged.sequenceFlows, + definitions = sorted.first().definitions.merge(sorted.drop(1).map { it.definitions }), + isExecutable = sorted.any { it.isExecutable }, + detectedEngine = sorted.firstNotNullOfOrNull { it.detectedEngine }, + variants = sorted.map { Variant(requireNotNull(it.variantName), it.flowNodes, it.sequenceFlows) }, + ) + } + + /** + * A single file still goes through the merge, so duplicate ids inside one model collapse the same way. + */ + private fun ProcessModel.deduplicated(): ProcessModel { + val merged = mergeScopes(listOf(scope())) + return copy( + flowNodes = merged.flowNodes, + sequenceFlows = merged.sequenceFlows, + definitions = definitions.merge(emptyList()), ) } - private fun mergeModelsWithSameProcessId(processId: String, models: List): MergedBpmnModel { - val modelsWithoutVariant = models.filter { it.variantName.isNullOrBlank() } - require(modelsWithoutVariant.isEmpty()) { + private fun requireVariantNames(processId: String, models: List) { + require(models.none { it.variantName.isNullOrBlank() }) { "Multiple BPMN files share process ID '$processId' but not all define a variantName. " + - "Add a variantName extension property to each process." + "Add a variantName extension property to each process." } - val sortedModels = models.sortedBy { requireNotNull(it.variantName) } - return MergedBpmnModel( - processId = processId, - flowNodes = mergeFlowNodes(sortedModels), - messages = mergeDistinctBy(sortedModels) { it.messages }, - signals = mergeDistinctBy(sortedModels) { it.signals }, - errors = mergeDistinctBy(sortedModels) { it.errors }, - escalations = mergeDistinctBy(sortedModels) { it.escalations }, - compensations = mergeDistinctBy(sortedModels) { it.compensations }, - variants = sortedModels.map { model -> - VariantData( - variantName = requireNotNull(model.variantName), - sequenceFlows = model.sequenceFlows, - flowNodes = model.flowNodes, - ) - }, - ) } /** - * Merges flow nodes across models by id, unioning additive list fields like `variables` - * and `attachedElements` so that variant-specific extension data (e.g. additionalInputVariables) - * is preserved instead of being dropped by simple deduplication. + * Merges the same scope across models by element id, unioning additive list fields like `variables` + * and `boundaryEventRefs` so that variant-specific extension data (e.g. additionalInputVariables) is + * preserved instead of being dropped by simple deduplication. Sub-process scopes are merged + * recursively, so nesting survives the merge. * - * The merged node's base attributes (`name`, `previousElements`, `followingElements`, etc.) come - * from the first model in the given order. Callers pass models pre-sorted by `variantName`, so - * the base is a deterministic function of the inputs rather than of filesystem read order. - * `attachedElements` is sorted so the union is order-independent regardless of input. + * A merged node's base attributes come from the first model in the given order. Callers pass models + * pre-sorted by `variantName`, so the result is a deterministic function of the inputs rather than of + * filesystem read order. */ - private fun mergeFlowNodes(models: List): List { - return models.flatMap { it.flowNodes } + private fun mergeScopes(scopes: List): FlowScope { + val nodesById = scopes + .flatMap { it.flowNodes } .filter { it.getRawName().isNotEmpty() } .groupBy { it.getRawName() } - .map { (_, duplicates) -> - duplicates.first().copy( - variables = duplicates.flatMap { it.variables }.distinct(), - attachedElements = duplicates.flatMap { it.attachedElements }.distinct().sorted(), - ) - } - } - - private fun > mergeDistinctBy( - models: List, - selector: (BpmnModel) -> List, - ): List { - return models.flatMap(selector) - .filter { it.getRawName().isNotEmpty() } - .distinctBy { it.getRawName() } + val mergedNodes = nodesById.map { (_, duplicates) -> mergeNodes(duplicates) } + val mergedFlows = scopes.flatMap { it.sequenceFlows }.distinctBy { it.getRawName() } + return FlowScope(mergedNodes, mergedFlows) } - private fun BpmnModel.sortContent(): BpmnModel { - return this.copy( - flowNodes = flowNodes.sortedBy { it.getRawName() }, - sequenceFlows = sequenceFlows.sortedBy { it.getRawName() }, - messages = messages.sortedBy { it.getRawName() }, - signals = signals.sortedBy { it.getRawName() }, - errors = errors.sortedBy { it.getRawName() }, - escalations = escalations.sortedBy { it.getRawName() }, - compensations = compensations.sortedBy { it.getRawName() }, - ) + private fun mergeNodes(duplicates: List): FlowNodeDefinition { + val base = duplicates.first() + val merged = base.mergedWith(duplicates.drop(1)) + if (merged !is FlowNodeDefinition.Activity.SubProcess) return merged + val childScopes = duplicates + .filterIsInstance() + .map { it.scope() } + val mergedChildren = mergeScopes(childScopes) + return merged.copy(flowNodes = mergedChildren.flowNodes, sequenceFlows = mergedChildren.sequenceFlows) } - private fun MergedBpmnModel.sortContent(): MergedBpmnModel { - return this.copy( - flowNodes = flowNodes.sortedBy { it.getRawName() }, - messages = messages.sortedBy { it.getRawName() }, - signals = signals.sortedBy { it.getRawName() }, - errors = errors.sortedBy { it.getRawName() }, - escalations = escalations.sortedBy { it.getRawName() }, - compensations = compensations.sortedBy { it.getRawName() }, + private fun ProcessModel.sortContent(): ProcessModel { + val sorted = scope().sorted() + return copy( + flowNodes = sorted.flowNodes, + sequenceFlows = sorted.sequenceFlows, + definitions = definitions.sorted(), variants = variants.map { variant -> - variant.copy( - sequenceFlows = variant.sequenceFlows.sortedBy { it.getRawName() }, - flowNodes = variant.flowNodes.sortedBy { it.getRawName() }, - ) + val sortedVariant = variant.scope().sorted() + variant.copy(flowNodes = sortedVariant.flowNodes, sequenceFlows = sortedVariant.sequenceFlows) }, ) } + + /** + * Sorts a scope and every scope nested inside it, so generated output is a function of the model + * rather than of the order the files happened to be read in. + */ + private fun FlowScope.sorted(): FlowScope { + val sortedNodes = flowNodes + .map { node -> if (node is FlowNodeDefinition.Activity.SubProcess) node.sortedRecursively() else node } + .sortedBy { it.getRawName() } + return FlowScope(sortedNodes, sequenceFlows.sortedBy { it.getRawName() }) + } + + private fun FlowNodeDefinition.Activity.SubProcess.sortedRecursively(): FlowNodeDefinition { + val sorted = scope().sorted() + return copy(flowNodes = sorted.flowNodes, sequenceFlows = sorted.sequenceFlows) + } + + /** + * Merging and sorting treat a scope as one value, so it is read out here. The models themselves name + * their two halves rather than storing the pair. + */ + private fun ProcessModel.scope() = FlowScope(flowNodes, sequenceFlows) + + private fun Variant.scope() = FlowScope(flowNodes, sequenceFlows) + + private fun FlowNodeDefinition.Activity.SubProcess.scope() = FlowScope(flowNodes, sequenceFlows) } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ApiObjectType.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ApiObjectType.kt deleted file mode 100644 index f829e300..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ApiObjectType.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.miragon.bpmn.domain.shared - -enum class ApiObjectType { - PROCESS_ID, - PROCESS_ENGINE, - ELEMENTS, - FLOWS, - RELATIONS, - CALL_ACTIVITIES, - MESSAGES, - SERVICE_TASKS, - TIMERS, - ERRORS, - ESCALATIONS, - COMPENSATIONS, - SIGNALS, - VARIABLES, - VARIANTS, -} \ No newline at end of file diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/BpmnNodeType.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/BpmnNodeType.kt deleted file mode 100644 index 5a51472a..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/BpmnNodeType.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.miragon.bpmn.domain.shared - -/** - * The type of a BPMN flow node, modelled along two axes: - * - * - a structural *base type* ([Gateway], [Event], [Activity]) that captures the node's shape, and - * - a concrete *subtype* that refines it (gateway kind, event shape, activity subtype). - * - * [Activity] mirrors the BPMN class hierarchy: an [Activity.Task] is itself an activity, alongside - * the compound [Activity.SubProcess] and the [Activity.CallActivity] leaf. [Event] carries a second - * sub-axis, [EventDefinitionType]. Invalid combinations (e.g. a timer gateway) are unrepresentable. - */ -sealed interface BpmnNodeType { - - data class Gateway(val kind: GatewayKind) : BpmnNodeType - - data class Event( - val shape: EventShape, - val definitionType: EventDefinitionType = EventDefinitionType.NONE, - ) : BpmnNodeType - - /** BPMN activities: atomic [Task]s, compound [SubProcess]es, and the [CallActivity] leaf. */ - sealed interface Activity : BpmnNodeType { - data class Task(val kind: TaskKind) : Activity - data class SubProcess(val kind: SubProcessKind) : Activity - object CallActivity : Activity - } - - /** Fallback for element types not covered by this model, and the default for manually built nodes. */ - object Unknown : BpmnNodeType -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CallActivityDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CallActivityDefinition.kt index e9bb3f4b..1ef0302d 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CallActivityDefinition.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CallActivityDefinition.kt @@ -2,11 +2,17 @@ package io.miragon.bpmn.domain.shared import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase +/** + * The called-process binding of a `bpmn:callActivity`: the target process id plus the variable mappings + * propagated in and out of it (`camunda:in` / `camunda:out` for Camunda 7 / Operaton, `zeebe:ioMapping` + * for Zeebe). + */ data class CallActivityDefinition( val id: String?, private val calledElement: String?, - val mappings: List = emptyList(), - val engineSpecificProperties: Map = emptyMap(), + val mappings: List = emptyList(), + val propagateAllInputVariables: Boolean? = null, + val propagateAllOutputVariables: Boolean? = null, ) : VariableMapping { override fun getName() = id?.toUpperSnakeCase() ?: "" override fun getValue() = calledElement ?: "" @@ -15,11 +21,14 @@ data class CallActivityDefinition( val inputMappings get() = mappings.filter { it.direction == VariableDirection.INPUT } val outputMappings get() = mappings.filter { it.direction == VariableDirection.OUTPUT } - val propagateAllInputVariables: Boolean? get() = engineSpecificProperties[PROPAGATE_ALL_INPUT_KEY] as? Boolean - val propagateAllOutputVariables: Boolean? get() = engineSpecificProperties[PROPAGATE_ALL_OUTPUT_KEY] as? Boolean - - companion object { - const val PROPAGATE_ALL_INPUT_KEY = "propagateAllInputVariables" - const val PROPAGATE_ALL_OUTPUT_KEY = "propagateAllOutputVariables" - } + /** + * One variable passed into or out of the called process (`camunda:in` / `camunda:out`, + * `zeebe:ioMapping`). + */ + data class Mapping( + val direction: VariableDirection, + val source: String? = null, + val sourceExpression: String? = null, + val target: String? = null, + ) } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CallActivityMapping.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CallActivityMapping.kt deleted file mode 100644 index 20ab5347..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CallActivityMapping.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.miragon.bpmn.domain.shared - -data class CallActivityMapping( - val direction: VariableDirection, - val source: String? = null, - val sourceExpression: String? = null, - val target: String? = null, -) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CompensationDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CompensationDefinition.kt index 067d4ebd..46d0a3b2 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CompensationDefinition.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CompensationDefinition.kt @@ -2,17 +2,22 @@ package io.miragon.bpmn.domain.shared import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase +/** + * A `bpmn:compensateEventDefinition`, keyed by the id of the event node that carries it. [activityRef] is + * the activity whose compensation handler is triggered — absent when the event compensates its whole scope. + */ data class CompensationDefinition( val id: String?, - val type: CompensationType, - val engineSpecificProperties: Map = emptyMap(), + val type: CompensationDefinition.Type, + val activityRef: String? = null, + val waitForCompletion: Boolean? = null, ) : VariableMapping { override fun getName() = id?.toUpperSnakeCase() ?: "" override fun getValue() = id ?: "" override fun getRawName() = id ?: "" - companion object { - const val ACTIVITY_REF_KEY = "activityRef" - const val WAIT_FOR_COMPLETION_KEY = "waitForCompletion" - } + /** + * Whether the event catches a compensation (boundary) or throws one (intermediate / end). + */ + enum class Type { CATCHING, THROWING } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CompensationType.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CompensationType.kt deleted file mode 100644 index ab41aaa3..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/CompensationType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.miragon.bpmn.domain.shared - -enum class CompensationType { - CATCHING, - THROWING, -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EngineExtension.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EngineExtension.kt new file mode 100644 index 00000000..6ba0e44a --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EngineExtension.kt @@ -0,0 +1,16 @@ +package io.miragon.bpmn.domain.shared + +/** + * A verbatim projection of a foreign-namespace XML element below `bpmn:extensionElements`. + * + * This is the lossless escape hatch for engine data bpmn-to-code does not normalise: [type] carries the + * namespace prefix (`zeebe:taskHeaders`, `camunda:properties`), [attributes] the element's own attributes, + * [children] its nested elements, and [body] its text content. Structure and namespace provenance are + * preserved, so a new engine feature needs no model change. See ADR 017. + */ +data class EngineExtension( + val type: String, + val attributes: Map = emptyMap(), + val children: List = emptyList(), + val body: String? = null, +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ErrorDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ErrorDefinition.kt deleted file mode 100644 index ea0e4b31..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ErrorDefinition.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.miragon.bpmn.domain.shared - -import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase - -data class ErrorDefinition( - val id: String?, - private val name: String?, - private val code: String?, - val engineSpecificProperties: Map = emptyMap(), -) : VariableMapping> { - override fun getName() = name?.toUpperSnakeCase() ?: "" - override fun getValue() = (name ?: "") to (code ?: "") - override fun getRawName() = name ?: "" - fun hasRequiredFields() = name != null && code != null -} \ No newline at end of file diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EscalationDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EscalationDefinition.kt deleted file mode 100644 index 3e83914f..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EscalationDefinition.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.miragon.bpmn.domain.shared - -import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase - -data class EscalationDefinition( - val id: String?, - private val name: String?, - private val code: String?, - val engineSpecificProperties: Map = emptyMap(), -) : VariableMapping> { - override fun getName() = name?.toUpperSnakeCase() ?: "" - override fun getValue() = (name ?: "") to (code ?: "") - override fun getRawName() = name ?: "" - fun hasRequiredFields() = name != null && code != null -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventDefinitionInstance.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventDefinitionInstance.kt new file mode 100644 index 00000000..ba04a67c --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventDefinitionInstance.kt @@ -0,0 +1,97 @@ +package io.miragon.bpmn.domain.shared + +/** + * One `bpmn:*EventDefinition` carried by an event node. + * + * BPMN models this as a **list** (`bpmn:CatchEvent.eventDefinitions`) — an event may carry several + * triggers — so [FlowNodeDefinition.Event] holds a list of these rather than a single kind. + * + * The `…Ref` fields point at the corresponding `bpmn:Definitions` root element (see [RootElementDefinition.Message], + * [RootElementDefinition.Signal], [RootElementDefinition.Error], [RootElementDefinition.Escalation]); the redundant name is kept alongside so + * validation rules can correlate without resolving the registry. + */ +sealed interface EventDefinitionInstance { + + val type: EventDefinitionInstance.Type + + data class Timer( + val timerType: TimerType? = null, + val expression: String? = null, + ) : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.TIMER + } + + data class Message( + val reference: MessageReference, + ) : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.MESSAGE + } + + data class Signal( + val signalRef: String? = null, + val signalName: String? = null, + ) : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.SIGNAL + } + + data class Error( + val errorRef: String? = null, + val errorName: String? = null, + val errorCode: String? = null, + ) : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.ERROR + } + + data class Escalation( + val escalationRef: String? = null, + val escalationName: String? = null, + val escalationCode: String? = null, + ) : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.ESCALATION + } + + data class Compensation( + val activityRef: String? = null, + val waitForCompletion: Boolean? = null, + ) : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.COMPENSATION + } + + data class Conditional( + val expression: String? = null, + ) : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.CONDITIONAL + } + + data class Link( + val linkName: String? = null, + ) : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.LINK + } + + data object Terminate : EventDefinitionInstance { + override val type = EventDefinitionInstance.Type.TERMINATE + } + + /** + * The kind of BPMN event definition carried by an [EventDefinitionInstance]. + * + * Per the BPMN 2.0 (OMG) spec an event definition acts as a *trigger* on catching events + * (start, intermediate-catch, boundary) and describes a *result* on throwing events + * (intermediate-throw, end). This enum captures that shared definition kind for both roles. + * + * An event with no definition at all is represented by an empty + * [FlowNodeDefinition.Event.eventDefinitions] list, not by a member of this enum. + */ + enum class Type { + TIMER, + MESSAGE, + ERROR, + SIGNAL, + ESCALATION, + COMPENSATION, + CONDITIONAL, + LINK, + TERMINATE, + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventDefinitionType.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventDefinitionType.kt deleted file mode 100644 index 6182f7f7..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventDefinitionType.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.miragon.bpmn.domain.shared - -/** - * The kind of BPMN event definition carried by an [BpmnNodeType.Event]. - * - * Per the BPMN 2.0 (OMG) spec an event definition acts as a *trigger* on catching events - * (start, intermediate-catch, boundary) and describes a *result* on throwing events - * (intermediate-throw, end). This enum captures that shared definition kind for both roles. - */ -enum class EventDefinitionType { - TIMER, - MESSAGE, - ERROR, - SIGNAL, - ESCALATION, - COMPENSATION, - NONE, -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventShape.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventShape.kt index e5eef1ae..61834ced 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventShape.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/EventShape.kt @@ -1,9 +1,13 @@ package io.miragon.bpmn.domain.shared -enum class EventShape { - START_EVENT, - END_EVENT, - INTERMEDIATE_CATCH_EVENT, - INTERMEDIATE_THROW_EVENT, - BOUNDARY_EVENT, +/** + * The structural kind of a BPMN event. [direction] follows from the shape: end and intermediate-throw + * events send their event definition as a *result*, every other shape catches it as a *trigger*. + */ +enum class EventShape(val direction: EventDirection) { + START_EVENT(EventDirection.CATCH), + END_EVENT(EventDirection.THROW), + INTERMEDIATE_CATCH_EVENT(EventDirection.CATCH), + INTERMEDIATE_THROW_EVENT(EventDirection.THROW), + BOUNDARY_EVENT(EventDirection.CATCH), } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowNodeDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowNodeDefinition.kt index 5bc3e183..ad17e366 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowNodeDefinition.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowNodeDefinition.kt @@ -2,27 +2,207 @@ package io.miragon.bpmn.domain.shared import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase -data class FlowNodeDefinition( - val id: String?, - val nodeType: BpmnNodeType = BpmnNodeType.Unknown, - val displayName: String? = null, - val properties: FlowNodeProperties = FlowNodeProperties.None, - val variables: List = emptyList(), - val attachedToRef: String? = null, - val interrupting: Boolean? = null, - val attachedElements: List = emptyList(), - val parentId: String? = null, - val previousElements: List = emptyList(), - val followingElements: List = emptyList(), - val engineSpecificProperties: Map = emptyMap(), -) : VariableMapping { - override fun getName() = id?.toUpperSnakeCase() ?: "" - override fun getValue() = id ?: "" - override fun getRawName() = id ?: "" +/** + * A BPMN flow node, modelled along the OMG class tree: [Gateway], [Event] and the compound [Activity] + * family ([Activity.Task], [Activity.SubProcess], [Activity.CallActivity]). + * + * Every subtype carries exactly the facets BPMN permits on it, so invalid combinations — a multi-instance + * gateway, a `calledElement` on an event, a `cancelActivity` flag on a task — are unrepresentable. See + * [ADR 017](../../../../../../../../docs/contributing/adr/017-bpmn-aligned-domain-model.md). + * + * [incoming] and [outgoing] hold **sequence-flow ids**, matching `bpmn:FlowNode.incoming` / `.outgoing`. + * Node-to-node adjacency is derived from the flows themselves — see `ProcessGraph`. + */ +sealed interface FlowNodeDefinition : VariableMapping { + + val id: String? + val displayName: String? + val incoming: List + val outgoing: List + val variables: List + val extensions: List + val engineAttributes: Map + + override fun getName(): String = id?.toUpperSnakeCase() ?: "" + override fun getValue(): String = id ?: "" + override fun getRawName(): String = id ?: "" + + /** + * Unions the additive list fields of [others] into this node, used when merging process variants that + * declare the same element with variant-specific extension data. Base attributes stay this node's. + */ + fun mergedWith(others: List): FlowNodeDefinition + + data class Gateway( + override val id: String?, + val kind: GatewayKind, + override val displayName: String? = null, + override val incoming: List = emptyList(), + override val outgoing: List = emptyList(), + val defaultFlow: String? = null, + override val variables: List = emptyList(), + override val extensions: List = emptyList(), + override val engineAttributes: Map = emptyMap(), + ) : FlowNodeDefinition { + override fun mergedWith(others: List): FlowNodeDefinition { + return copy(variables = mergeVariables(this, others)) + } + } + + /** + * A BPMN event. [shape] is the structural kind (start / end / intermediate / boundary), + * [eventDefinitions] the triggers or results it carries — a list, because BPMN allows several. + * + * [attachedToRef] and [interrupting] are only populated where BPMN defines them: `attachedToRef` and + * `cancelActivity` on a boundary event, `isInterrupting` on an event sub-process start event. + * [implementation] covers `camunda:ServiceTaskLike` on a message throw event. + */ + data class Event( + override val id: String?, + val shape: EventShape, + override val displayName: String? = null, + override val incoming: List = emptyList(), + override val outgoing: List = emptyList(), + val eventDefinitions: List = emptyList(), + val attachedToRef: String? = null, + val interrupting: Boolean? = null, + val implementation: TaskImplementation? = null, + val ioMapping: IoMapping? = null, + override val variables: List = emptyList(), + override val extensions: List = emptyList(), + override val engineAttributes: Map = emptyMap(), + ) : FlowNodeDefinition { + override fun mergedWith(others: List): FlowNodeDefinition { + return copy(variables = mergeVariables(this, others)) + } + } + + /** + * A BPMN activity. Multi-instance loop characteristics, I/O mappings, boundary-event attachments and + * the compensation flag are defined on `bpmn:Activity`, so they live here rather than on individual + * task kinds. + */ + sealed interface Activity : FlowNodeDefinition { + val multiInstance: MultiInstanceDefinition? + val ioMapping: IoMapping? + val boundaryEventRefs: List + val isForCompensation: Boolean + val defaultFlow: String? + + /** + * An atomic activity. [message] is populated for send and receive tasks, which reference a + * `bpmn:Message` directly instead of through an event definition. + */ + data class Task( + override val id: String?, + val kind: TaskKind, + override val displayName: String? = null, + override val incoming: List = emptyList(), + override val outgoing: List = emptyList(), + val implementation: TaskImplementation? = null, + val message: MessageReference? = null, + override val multiInstance: MultiInstanceDefinition? = null, + override val ioMapping: IoMapping? = null, + override val boundaryEventRefs: List = emptyList(), + override val isForCompensation: Boolean = false, + override val defaultFlow: String? = null, + override val variables: List = emptyList(), + override val extensions: List = emptyList(), + override val engineAttributes: Map = emptyMap(), + ) : Activity { + override fun mergedWith(others: List): FlowNodeDefinition { + return copy( + variables = mergeVariables(this, others), + boundaryEventRefs = mergeBoundaryEventRefs(this, others), + ) + } + } + + /** + * A sub-process, transaction or event sub-process. Owns its children **and its own sequence + * flows**, mirroring `bpmn:FlowElementsContainer.flowElements`, so a flow always knows its scope. + */ + data class SubProcess( + override val id: String?, + val kind: SubProcessKind, + override val displayName: String? = null, + override val incoming: List = emptyList(), + override val outgoing: List = emptyList(), + val flowNodes: List = emptyList(), + val sequenceFlows: List = emptyList(), + override val multiInstance: MultiInstanceDefinition? = null, + override val ioMapping: IoMapping? = null, + override val boundaryEventRefs: List = emptyList(), + override val isForCompensation: Boolean = false, + override val defaultFlow: String? = null, + override val variables: List = emptyList(), + override val extensions: List = emptyList(), + override val engineAttributes: Map = emptyMap(), + ) : Activity { + override fun mergedWith(others: List): FlowNodeDefinition { + return copy( + variables = mergeVariables(this, others), + boundaryEventRefs = mergeBoundaryEventRefs(this, others), + ) + } + } + + data class CallActivity( + override val id: String?, + val definition: CallActivityDefinition, + override val displayName: String? = null, + override val incoming: List = emptyList(), + override val outgoing: List = emptyList(), + override val multiInstance: MultiInstanceDefinition? = null, + override val ioMapping: IoMapping? = null, + override val boundaryEventRefs: List = emptyList(), + override val isForCompensation: Boolean = false, + override val defaultFlow: String? = null, + override val variables: List = emptyList(), + override val extensions: List = emptyList(), + override val engineAttributes: Map = emptyMap(), + ) : Activity { + override fun mergedWith(others: List): FlowNodeDefinition { + return copy( + variables = mergeVariables(this, others), + boundaryEventRefs = mergeBoundaryEventRefs(this, others), + ) + } + } + } + + /** + * Fallback for element types not covered by this model, and the default for manually built nodes. + */ + data class Unknown( + override val id: String?, + override val displayName: String? = null, + override val incoming: List = emptyList(), + override val outgoing: List = emptyList(), + override val variables: List = emptyList(), + override val extensions: List = emptyList(), + override val engineAttributes: Map = emptyMap(), + ) : FlowNodeDefinition { + override fun mergedWith(others: List): FlowNodeDefinition { + return copy(variables = mergeVariables(this, others)) + } + } companion object { - const val ASYNC_BEFORE_KEY = "asyncBefore" - const val ASYNC_AFTER_KEY = "asyncAfter" - const val EXCLUSIVE_KEY = "exclusive" + + private fun mergeVariables( + node: FlowNodeDefinition, + others: List, + ): List { + return (node.variables + others.flatMap { it.variables }).distinct() + } + + private fun mergeBoundaryEventRefs( + node: Activity, + others: List, + ): List { + val fromOthers = others.filterIsInstance().flatMap { it.boundaryEventRefs } + return (node.boundaryEventRefs + fromOthers).distinct().sorted() + } } -} \ No newline at end of file +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowNodeProperties.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowNodeProperties.kt deleted file mode 100644 index ed659bf9..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowNodeProperties.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.miragon.bpmn.domain.shared - -sealed interface FlowNodeProperties { - object None : FlowNodeProperties - data class ServiceTask(val definition: ServiceTaskDefinition) : FlowNodeProperties - data class Timer(val definition: TimerDefinition) : FlowNodeProperties - data class CallActivity(val definition: CallActivityDefinition) : FlowNodeProperties - data class MessageEvent(val name: String, val direction: EventDirection) : FlowNodeProperties - data class SignalEvent(val name: String, val direction: EventDirection) : FlowNodeProperties -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowScope.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowScope.kt new file mode 100644 index 00000000..62a70ac6 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/FlowScope.kt @@ -0,0 +1,16 @@ +package io.miragon.bpmn.domain.shared + +/** + * The flow nodes and sequence flows owned by one BPMN scope. + * + * BPMN calls this a `bpmn:FlowElementsContainer`: a process and a sub-process are containers in exactly + * the same sense, each owning its children *and* the flows between them. Naming it once is what lets the + * process model, its variants and [FlowNodeDefinition.Activity.SubProcess] share the concept instead of + * each carrying the two lists apart — and what lets a reader hand back one value rather than a pair. + * + * This is the store. [ProcessGraph] is the flattened projection over it. + */ +data class FlowScope( + val flowNodes: List = emptyList(), + val sequenceFlows: List = emptyList(), +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/IoMapping.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/IoMapping.kt new file mode 100644 index 00000000..2ad875f7 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/IoMapping.kt @@ -0,0 +1,23 @@ +package io.miragon.bpmn.domain.shared + +/** + * A node's input/output parameter mapping, normalised across engines: `zeebe:ioMapping` and + * `camunda:inputOutput` both map onto this shape. Values are preserved verbatim, so a FEEL expression + * (`=order.id`), a JUEL expression (`${'$'}{order.id}`) and a static value are all round-tripped unchanged. + */ +data class IoMapping( + val inputs: List = emptyList(), + val outputs: List = emptyList(), +) { + + fun isEmpty(): Boolean = inputs.isEmpty() && outputs.isEmpty() + + /** + * One input or output parameter. [target] is the variable being written, [source] the expression or + * static value it is bound to (absent for `camunda:outputParameter` bodies that carry no value). + */ + data class Parameter( + val target: String, + val source: String? = null, + ) +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MessageDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MessageDefinition.kt deleted file mode 100644 index f6be4c44..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MessageDefinition.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.miragon.bpmn.domain.shared - -import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase - -data class MessageDefinition( - val id: String?, - private val name: String?, - val engineSpecificProperties: Map = emptyMap(), -) : VariableMapping { - override fun getName() = name?.toUpperSnakeCase() ?: "" - override fun getValue() = name ?: "" - override fun getRawName() = name ?: "" - fun hasName() = name != null -} \ No newline at end of file diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MessageReference.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MessageReference.kt new file mode 100644 index 00000000..c7ed8278 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MessageReference.kt @@ -0,0 +1,14 @@ +package io.miragon.bpmn.domain.shared + +/** + * A reference to a `bpmn:Message` root element. + * + * Carried both by a message event definition and by a send/receive task (`bpmn:ReceiveTask.messageRef`), + * so correlation-related rules can treat the two uniformly. [messageName] is kept alongside the reference + * for convenience; everything else about the message — including its correlation key — lives on + * [RootElementDefinition.Message] in the model's registry. + */ +data class MessageReference( + val messageRef: String? = null, + val messageName: String? = null, +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MultiInstanceDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MultiInstanceDefinition.kt new file mode 100644 index 00000000..eb00ec5b --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/MultiInstanceDefinition.kt @@ -0,0 +1,19 @@ +package io.miragon.bpmn.domain.shared + +/** + * `bpmn:multiInstanceLoopCharacteristics` on an activity, normalised across engines. + * + * [sequential] comes from the standard `isSequential` attribute and decides whether the instances run one + * after another or concurrently. The collection/element bindings come from `zeebe:loopCharacteristics` + * (Zeebe) or `camunda:collection` / `camunda:elementVariable` (Camunda 7 / Operaton); [cardinality] and + * [completionCondition] are the standard `loopCardinality` / `completionCondition` expressions. + */ +data class MultiInstanceDefinition( + val sequential: Boolean = false, + val inputCollection: String? = null, + val inputElement: String? = null, + val outputCollection: String? = null, + val outputElement: String? = null, + val cardinality: String? = null, + val completionCondition: String? = null, +) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ProcessGraph.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ProcessGraph.kt new file mode 100644 index 00000000..2fe53c82 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ProcessGraph.kt @@ -0,0 +1,83 @@ +package io.miragon.bpmn.domain.shared + +/** + * Derived views over a scope tree of [FlowNodeDefinition]s. + * + * The tree is the store — a sub-process owns its children and its own sequence flows — while merging, + * validation, collision detection and code generation all reason over a flat node set. This class + * provides that projection, plus the node-to-node adjacency that [FlowNodeDefinition.incoming] / + * [FlowNodeDefinition.outgoing] express as sequence-flow references. + */ +class ProcessGraph( + private val flowNodes: List, + private val sequenceFlows: List, +) { + + /** + * Every node in the tree, depth-first, each container immediately followed by its children. + */ + val allFlowNodes: List by lazy { flatten(flowNodes) } + + /** + * Every sequence flow in the tree, the root scope's first, then each sub-process scope's. + */ + val allSequenceFlows: List by lazy { sequenceFlows + nestedFlows(flowNodes) } + + private val flowById: Map by lazy { + allSequenceFlows.mapNotNull { flow -> flow.id?.let { it to flow } }.toMap() + } + + private val parentIdByNodeId: Map by lazy { buildParentIndex(flowNodes, null) } + + fun parentIdOf(nodeId: String?): String? = nodeId?.let { parentIdByNodeId[it] } + + /** + * Ids of the flow nodes that precede [node], resolved through its incoming sequence flows. + */ + fun previousElementsOf(node: FlowNodeDefinition): List { + return node.incoming.mapNotNull { flowById[it]?.sourceRef } + } + + /** + * Ids of the flow nodes that follow [node], resolved through its outgoing sequence flows. + */ + fun followingElementsOf(node: FlowNodeDefinition): List { + return node.outgoing.mapNotNull { flowById[it]?.targetRef } + } + + /** + * Boundary events attached to [node]; empty for anything that is not an activity. + */ + fun attachedElementsOf(node: FlowNodeDefinition): List { + return (node as? FlowNodeDefinition.Activity)?.boundaryEventRefs ?: emptyList() + } + + private fun flatten(nodes: List): List { + return nodes.flatMap { node -> + if (node is FlowNodeDefinition.Activity.SubProcess) { + listOf(node) + flatten(node.flowNodes) + } else { + listOf(node) + } + } + } + + private fun nestedFlows(nodes: List): List { + return nodes.filterIsInstance() + .flatMap { it.sequenceFlows + nestedFlows(it.flowNodes) } + } + + private fun buildParentIndex( + nodes: List, + parentId: String?, + ): Map { + return buildMap { + nodes.forEach { node -> + if (parentId != null && node.id != null) put(node.id!!, parentId) + if (node is FlowNodeDefinition.Activity.SubProcess) { + putAll(buildParentIndex(node.flowNodes, node.id)) + } + } + } + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/RootElementDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/RootElementDefinition.kt new file mode 100644 index 00000000..76456452 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/RootElementDefinition.kt @@ -0,0 +1,75 @@ +package io.miragon.bpmn.domain.shared + +import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase + +/** + * A `bpmn:Definitions` root element — a message, signal, error or escalation. + * + * Flow nodes reference these by [id], so registries are keyed by id rather than by name: BPMN permits two + * root elements with the same name and distinct ids (a modeller typing the same message name twice instead + * of picking the existing one), and both have to stay resolvable. Collapsing them to one generated constant + * is a code-generation concern and happens there. + * + * The four are grouped as a [RootElements] registry on the process model. + */ +sealed interface RootElementDefinition { + + val id: String? + + /** + * A `bpmn:Message` root element. [correlationKey] is the Zeebe `zeebe:subscription` expression, which + * is declared on the message element itself and so belongs here rather than on each referencing node. + */ + data class Message( + override val id: String?, + private val name: String?, + val correlationKey: String? = null, + ) : RootElementDefinition, VariableMapping { + override fun getName() = name?.toUpperSnakeCase() ?: "" + override fun getValue() = name ?: "" + override fun getRawName() = name ?: "" + } + + /** + * A `bpmn:Signal` root element, referenced by every signal event definition that publishes or catches + * it. + * + * [hasName] has no counterpart on [Message] on purpose: `MissingSignalNameRule` checks the registry, + * while `MissingMessageNameRule` checks the reference on the node. + */ + data class Signal( + override val id: String?, + private val name: String?, + ) : RootElementDefinition, VariableMapping { + override fun getName() = name?.toUpperSnakeCase() ?: "" + override fun getValue() = name ?: "" + override fun getRawName() = name ?: "" + fun hasName() = name != null + } + + /** + * A `bpmn:Error` root element, referenced by error event definitions via `errorRef`. + */ + data class Error( + override val id: String?, + private val name: String?, + private val code: String?, + ) : RootElementDefinition, VariableMapping> { + override fun getName() = name?.toUpperSnakeCase() ?: "" + override fun getValue() = (name ?: "") to (code ?: "") + override fun getRawName() = name ?: "" + } + + /** + * A `bpmn:Escalation` root element, referenced by escalation event definitions via `escalationRef`. + */ + data class Escalation( + override val id: String?, + private val name: String?, + private val code: String?, + ) : RootElementDefinition, VariableMapping> { + override fun getName() = name?.toUpperSnakeCase() ?: "" + override fun getValue() = (name ?: "") to (code ?: "") + override fun getRawName() = name ?: "" + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/RootElements.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/RootElements.kt new file mode 100644 index 00000000..66eecd42 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/RootElements.kt @@ -0,0 +1,48 @@ +package io.miragon.bpmn.domain.shared + +/** + * The `bpmn:Definitions` root-element registries of a process, keyed by each element's own id. + * + * These four lists are always handled together — merged, filtered and sorted as a unit — so they travel as + * one value rather than as four parallel fields on every model, service and mapper. Flow nodes point into + * them through their `…Ref` fields; see [RootElementDefinition]. + */ +data class RootElements( + val messages: List = emptyList(), + val signals: List = emptyList(), + val errors: List = emptyList(), + val escalations: List = emptyList(), +) { + + /** + * Unions [others] into this registry, keyed by the element's **own id** (falling back to its name for + * the rare id-less element) — the same key the extractor uses and the one flow nodes reference. + * Deduplicating by name alone would drop one of two same-named `bpmn:Message` elements and leave its + * `messageRef` dangling. + */ + fun merge(others: List): RootElements { + val all = listOf(this) + others + return RootElements( + messages = all.flatMap { it.messages }.distinctById(), + signals = all.flatMap { it.signals }.distinctById(), + errors = all.flatMap { it.errors }.distinctById(), + escalations = all.flatMap { it.escalations }.distinctById(), + ) + } + + /** + * Sorted by name, so generated output is a function of the model rather than of read order. + */ + fun sorted(): RootElements { + return RootElements( + messages = messages.sortedBy { it.getRawName() }, + signals = signals.sortedBy { it.getRawName() }, + errors = errors.sortedBy { it.getRawName() }, + escalations = escalations.sortedBy { it.getRawName() }, + ) + } + + private fun List.distinctById(): List where T : VariableMapping<*>, T : RootElementDefinition { + return filter { it.getRawName().isNotEmpty() }.distinctBy { it.id ?: it.getRawName() } + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ServiceTaskDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ServiceTaskDefinition.kt index ff682842..f62d05dc 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ServiceTaskDefinition.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/ServiceTaskDefinition.kt @@ -2,19 +2,18 @@ package io.miragon.bpmn.domain.shared import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase +/** + * The service-task-like implementation of one node, as consumed by the generated `ServiceTasks` object. + * Wraps the typed [TaskImplementation] and exposes its [TaskImplementation.reference] as the constant value. + */ data class ServiceTaskDefinition( val id: String?, - val engineSpecificProperties: Map = emptyMap(), + val implementation: TaskImplementation, ) : VariableMapping { - override fun getName() = implementationType?.toUpperSnakeCase() ?: "" - override fun getValue() = implementationType ?: "" - override fun getRawName() = implementationType ?: "" - fun hasImplementation() = implementationType != null + override fun getName() = reference?.toUpperSnakeCase() ?: "" + override fun getValue() = reference ?: "" + override fun getRawName() = reference ?: "" + fun hasImplementation() = reference != null - private val implementationType: String? get() = engineSpecificProperties[IMPL_VALUE_KEY] as? String - - companion object { - const val IMPL_VALUE_KEY = "implementationValue" - const val IMPL_KIND_KEY = "implementationKind" - } + private val reference: String? get() = implementation.reference } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/SignalDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/SignalDefinition.kt deleted file mode 100644 index e5d3fd47..00000000 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/SignalDefinition.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.miragon.bpmn.domain.shared - -import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase - -data class SignalDefinition( - val id: String?, - private val name: String?, - val engineSpecificProperties: Map = emptyMap(), -) : VariableMapping { - override fun getName() = name?.toUpperSnakeCase() ?: "" - override fun getValue() = name ?: "" - override fun getRawName() = name ?: "" - fun hasName() = name != null -} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TaskImplementation.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TaskImplementation.kt new file mode 100644 index 00000000..1a31622a --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TaskImplementation.kt @@ -0,0 +1,66 @@ +package io.miragon.bpmn.domain.shared + +/** + * How a service-task-like node is implemented, normalised across engines. + * + * Per `camunda:ServiceTaskLike` (and its Zeebe equivalent) this applies to service tasks, business-rule + * tasks, send tasks **and** message event definitions, which is why it is carried by both + * [FlowNodeDefinition.Activity.Task] and [FlowNodeDefinition.Event]. + * + * [reference] is the single string a consumer subscribes to or dispatches on — the Zeebe job type, the + * Camunda 7 topic, the delegate expression, … It is `null` only for [Unspecified], i.e. a node that is + * service-task-like but carries no implementation configuration at all. + */ +sealed interface TaskImplementation { + + val reference: String? + + /** + * Declared as service-task-like, but nothing is configured. Flagged by the validation rules. + */ + data object Unspecified : TaskImplementation { + override val reference: String? = null + } + + /** + * Zeebe `zeebe:taskDefinition` handled by a job worker. + */ + data class JobWorker(val jobType: String, val retries: String? = null) : TaskImplementation { + override val reference: String get() = jobType + } + + /** + * Zeebe `zeebe:taskDefinition` backed by an element template (outbound connector). + */ + data class Connector(val jobType: String, val templateId: String? = null, val retries: String? = null) : TaskImplementation { + override val reference: String get() = jobType + } + + /** + * Camunda 7 / Operaton `camunda:topic` handled by an external task worker. + */ + data class ExternalTask(val topic: String) : TaskImplementation { + override val reference: String get() = topic + } + + /** + * Camunda 7 / Operaton `camunda:class`. + */ + data class JavaClass(val className: String) : TaskImplementation { + override val reference: String get() = className + } + + /** + * Camunda 7 / Operaton `camunda:delegateExpression`. + */ + data class DelegateExpression(val expression: String) : TaskImplementation { + override val reference: String get() = expression + } + + /** + * Camunda 7 / Operaton `camunda:expression`. + */ + data class Expression(val expression: String) : TaskImplementation { + override val reference: String get() = expression + } +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TimerDefinition.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TimerDefinition.kt index 20a0a1ce..532fc46f 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TimerDefinition.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TimerDefinition.kt @@ -2,14 +2,17 @@ package io.miragon.bpmn.domain.shared import io.miragon.bpmn.domain.utils.StringUtils.toUpperSnakeCase +/** + * A timer event definition, keyed by the id of the event node that carries it — unlike messages, signals + * and errors, `bpmn:timerEventDefinition` is not a `bpmn:Definitions` root element. + */ data class TimerDefinition( val id: String?, - private val type: String?, - private val value: String?, - val engineSpecificProperties: Map = emptyMap(), + val type: TimerType?, + val expression: String?, ) : VariableMapping> { override fun getName() = id?.toUpperSnakeCase() ?: "" - override fun getValue() = (type ?: "") to (value ?: "") + override fun getValue() = (type?.label ?: "") to (expression ?: "") override fun getRawName() = id ?: "" - fun hasTimerType() = type != null && value != null -} \ No newline at end of file + fun hasTimerType() = type != null && expression != null +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TimerType.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TimerType.kt new file mode 100644 index 00000000..66014be6 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/shared/TimerType.kt @@ -0,0 +1,13 @@ +package io.miragon.bpmn.domain.shared + +/** + * The kind of `bpmn:timerEventDefinition` child that carries the timer expression. + * + * [label] is the BPMN-flavoured spelling used in the generated `BpmnTimer` constants — a code-generation + * concern that still lives here because [TimerDefinition] exposes it through [VariableMapping]. + */ +enum class TimerType(val label: String) { + DATE("Date"), + DURATION("Duration"), + CYCLE("Cycle"), +} diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/EmptyProcessRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/EmptyProcessRule.kt index 856ecdb5..40d614a4 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/EmptyProcessRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/EmptyProcessRule.kt @@ -14,7 +14,7 @@ class EmptyProcessRule : SingleModelValidationRule { override val severity = Severity.WARN override fun validate(context: SingleModelValidationContext): List { - if (context.model.flowNodes.isEmpty()) { + if (context.model.allFlowNodes.isEmpty()) { return listOf( ValidationViolation( ruleId = id, diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/EngineMismatchRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/EngineMismatchRule.kt index 0f0dcb38..95168a43 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/EngineMismatchRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/EngineMismatchRule.kt @@ -1,6 +1,6 @@ package io.miragon.bpmn.domain.validation.rules -import io.miragon.bpmn.domain.BpmnModel +import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.ProcessEngine import io.miragon.bpmn.domain.validation.SingleModelValidationRule import io.miragon.bpmn.domain.validation.model.Severity @@ -9,7 +9,7 @@ import io.miragon.bpmn.domain.validation.model.ValidationViolation /** * Flags when a model targets a different engine than the selected one, using the engine detected - * during extraction ([BpmnModel.detectedEngine]). + * during extraction ([ProcessModel.detectedEngine]). * * Zeebe (Camunda 8), Camunda 7, and Operaton each use their own namespace and extension elements, * and an extractor only understands its own — so generating for the wrong engine yields a broken API. @@ -25,7 +25,7 @@ class EngineMismatchRule : SingleModelValidationRule { override val severity = Severity.ERROR override fun validate(context: SingleModelValidationContext): List { - val detected = (context.model as? BpmnModel)?.detectedEngine + val detected = context.model.detectedEngine val selected = context.engine val violation = when { detected == selected -> null diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingElementIdRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingElementIdRule.kt index 11e45058..c88307a7 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingElementIdRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingElementIdRule.kt @@ -19,7 +19,7 @@ class MissingElementIdRule : SingleModelValidationRule { override fun validate(context: SingleModelValidationContext): List { val model = context.model - return model.flowNodes + return model.allFlowNodes .filter { it.id == null } .map { ValidationViolation( diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingErrorDefinitionRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingErrorDefinitionRule.kt index 219cd68b..59cc7c7b 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingErrorDefinitionRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingErrorDefinitionRule.kt @@ -14,13 +14,13 @@ class MissingErrorDefinitionRule : SingleModelValidationRule { override val severity = Severity.ERROR override fun validate(context: SingleModelValidationContext): List { - return context.model.errors - .filter { !it.hasRequiredFields() } - .map { error -> + return context.model.errorUsages() + .filter { (_, error) -> error.errorRef != null && (error.errorName == null || error.errorCode == null) } + .map { (node, _) -> ValidationViolation( ruleId = id, severity = severity, - elementId = error.id, + elementId = node.id, processId = context.model.processId, message = "Error event definition is missing a 'name' or 'errorCode' attribute.", ) diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingMessageNameRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingMessageNameRule.kt index aa1a5aff..c9aef89d 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingMessageNameRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingMessageNameRule.kt @@ -1,12 +1,16 @@ package io.miragon.bpmn.domain.validation.rules +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.TaskKind import io.miragon.bpmn.domain.validation.SingleModelValidationRule import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import io.miragon.bpmn.domain.validation.model.ValidationViolation /** - * Flags message elements that are missing a 'name' attribute. + * Flags message-bearing nodes whose message carries no 'name' — a message event definition or a + * send/receive task that cannot be correlated against. */ class MissingMessageNameRule : SingleModelValidationRule { @@ -14,16 +18,32 @@ class MissingMessageNameRule : SingleModelValidationRule { override val severity = Severity.ERROR override fun validate(context: SingleModelValidationContext): List { - return context.model.messages - .filter { !it.hasName() } - .map { message -> + return context.model.allFlowNodes + .filter { it.hasNamelessMessage() } + .map { node -> ValidationViolation( ruleId = id, severity = severity, - elementId = message.id, + elementId = node.id, processId = context.model.processId, message = "Message element is missing a 'name' attribute.", ) } } + + private fun FlowNodeDefinition.hasNamelessMessage(): Boolean = when (this) { + is FlowNodeDefinition.Event -> + eventDefinitions.filterIsInstance() + .any { it.reference.messageRef != null && it.reference.messageName == null } + is FlowNodeDefinition.Activity.Task -> + kind in messageTaskKinds && message != null && message.messageName == null + else -> false + } + + private companion object { + val messageTaskKinds = setOf( + TaskKind.RECEIVE, + TaskKind.SEND, + ) + } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingSignalNameRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingSignalNameRule.kt index 2b8de33d..1a442350 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingSignalNameRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/MissingSignalNameRule.kt @@ -14,7 +14,7 @@ class MissingSignalNameRule : SingleModelValidationRule { override val severity = Severity.ERROR override fun validate(context: SingleModelValidationContext): List { - return context.model.signals + return context.model.definitions.signals .filter { !it.hasName() } .map { ValidationViolation( diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtMessageThrowRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtMessageThrowRule.kt index abb62fc4..8b27c0c7 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtMessageThrowRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtMessageThrowRule.kt @@ -1,8 +1,5 @@ package io.miragon.bpmn.domain.validation.rules -import io.miragon.bpmn.domain.ProcessModel -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.EventDirection import io.miragon.bpmn.domain.validation.CrossModelValidationRule import io.miragon.bpmn.domain.validation.model.CrossModelValidationContext @@ -10,9 +7,9 @@ import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.ValidationViolation /** - * Flags a message that is thrown (message end / intermediate throw event) but never caught anywhere in - * the loaded fileset — silently lost cross-process communication that no single-model rule can detect, - * since the catcher may live in another process file. + * Flags a message that is thrown (message end / intermediate throw event, send task) but never caught + * anywhere in the loaded fileset — silently lost cross-process communication that no single-model rule can + * detect, since the catcher may live in another process file. * * Reported as WARN, not ERROR: a legitimate consumer outside the loaded fileset is possible, so the * rule can only warn. Cross-model — only meaningful with the whole related fileset loaded together, @@ -24,46 +21,24 @@ class UncaughtMessageThrowRule : CrossModelValidationRule { override val severity = Severity.WARN override fun validate(context: CrossModelValidationContext): List { - val thrownMessages = thrownMessages(context) - val caughtMessageNames = caughtMessageNames(context) - - return thrownMessages - .filterNot { (_, _, message) -> message.name in caughtMessageNames } - .map { (model, node, message) -> - ValidationViolation( - ruleId = id, - severity = severity, - elementId = node.id, - processId = model.processId, - message = "Message '${message.name}' is thrown by '${node.id}' but has no catching event in the loaded models.", - ) - } - } + val caughtNames = context.models + .flatMap { it.messageUsages() } + .filter { it.direction == EventDirection.CATCH } + .map { it.name } + .toSet() - private fun thrownMessages( - context: CrossModelValidationContext, - ): List> { return context.models.flatMap { model -> - model.messageEvents(EventDirection.THROW).map { (node, message) -> Triple(model, node, message) } + model.messageUsages() + .filter { it.direction == EventDirection.THROW && it.name !in caughtNames } + .map { usage -> + ValidationViolation( + ruleId = id, + severity = severity, + elementId = usage.node.id, + processId = model.processId, + message = "Message '${usage.name}' is thrown by '${usage.node.id}' but has no catching event in the loaded models.", + ) + } } } - - private fun caughtMessageNames(context: CrossModelValidationContext): Set { - return context.models - .flatMap { model -> model.messageEvents(EventDirection.CATCH) } - .map { (_, message) -> message.name } - .toSet() - } - - private fun ProcessModel.messageEvents( - direction: EventDirection, - ): List> { - return flowNodes - .mapNotNull { node -> node.messageEvent()?.let { node to it } } - .filter { (_, message) -> message.direction == direction } - } - - private fun FlowNodeDefinition.messageEvent(): FlowNodeProperties.MessageEvent? { - return properties as? FlowNodeProperties.MessageEvent - } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtSignalThrowRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtSignalThrowRule.kt index fb470648..3c70f3a1 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtSignalThrowRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtSignalThrowRule.kt @@ -1,9 +1,6 @@ package io.miragon.bpmn.domain.validation.rules -import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.EventDirection -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.validation.CrossModelValidationRule import io.miragon.bpmn.domain.validation.model.CrossModelValidationContext import io.miragon.bpmn.domain.validation.model.Severity @@ -24,46 +21,24 @@ class UncaughtSignalThrowRule : CrossModelValidationRule { override val severity = Severity.WARN override fun validate(context: CrossModelValidationContext): List { - val thrownSignals = thrownSignals(context) - val caughtSignalNames = caughtSignalNames(context) - - return thrownSignals - .filterNot { (_, _, signal) -> signal.name in caughtSignalNames } - .map { (model, node, signal) -> - ValidationViolation( - ruleId = id, - severity = severity, - elementId = node.id, - processId = model.processId, - message = "Signal '${signal.name}' is thrown by '${node.id}' but has no catching event in the loaded models.", - ) - } - } + val caughtNames = context.models + .flatMap { it.signalUsages() } + .filter { it.direction == EventDirection.CATCH } + .map { it.name } + .toSet() - private fun thrownSignals( - context: CrossModelValidationContext, - ): List> { return context.models.flatMap { model -> - model.signalEvents(EventDirection.THROW).map { (node, signal) -> Triple(model, node, signal) } + model.signalUsages() + .filter { it.direction == EventDirection.THROW && it.name !in caughtNames } + .map { usage -> + ValidationViolation( + ruleId = id, + severity = severity, + elementId = usage.node.id, + processId = model.processId, + message = "Signal '${usage.name}' is thrown by '${usage.node.id}' but has no catching event in the loaded models.", + ) + } } } - - private fun caughtSignalNames(context: CrossModelValidationContext): Set { - return context.models - .flatMap { model -> model.signalEvents(EventDirection.CATCH) } - .map { (_, signal) -> signal.name } - .toSet() - } - - private fun ProcessModel.signalEvents( - direction: EventDirection, - ): List> { - return flowNodes - .mapNotNull { node -> node.signalEvent()?.let { node to it } } - .filter { (_, signal) -> signal.direction == direction } - } - - private fun FlowNodeDefinition.signalEvent(): FlowNodeProperties.SignalEvent? { - return properties as? FlowNodeProperties.SignalEvent - } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UnpublishedSignalCatchRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UnpublishedSignalCatchRule.kt index 5acf8cf1..d47a4343 100644 --- a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UnpublishedSignalCatchRule.kt +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UnpublishedSignalCatchRule.kt @@ -1,9 +1,6 @@ package io.miragon.bpmn.domain.validation.rules -import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.EventDirection -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.validation.CrossModelValidationRule import io.miragon.bpmn.domain.validation.model.CrossModelValidationContext import io.miragon.bpmn.domain.validation.model.Severity @@ -25,46 +22,24 @@ class UnpublishedSignalCatchRule : CrossModelValidationRule { override val severity = Severity.WARN override fun validate(context: CrossModelValidationContext): List { - val caughtSignals = caughtSignals(context) - val thrownSignalNames = thrownSignalNames(context) - - return caughtSignals - .filterNot { (_, _, signal) -> signal.name in thrownSignalNames } - .map { (model, node, signal) -> - ValidationViolation( - ruleId = id, - severity = severity, - elementId = node.id, - processId = model.processId, - message = "Signal '${signal.name}' is caught by '${node.id}' but has no throwing event in the loaded models.", - ) - } - } + val thrownNames = context.models + .flatMap { it.signalUsages() } + .filter { it.direction == EventDirection.THROW } + .map { it.name } + .toSet() - private fun caughtSignals( - context: CrossModelValidationContext, - ): List> { return context.models.flatMap { model -> - model.signalEvents(EventDirection.CATCH).map { (node, signal) -> Triple(model, node, signal) } + model.signalUsages() + .filter { it.direction == EventDirection.CATCH && it.name !in thrownNames } + .map { usage -> + ValidationViolation( + ruleId = id, + severity = severity, + elementId = usage.node.id, + processId = model.processId, + message = "Signal '${usage.name}' is caught by '${usage.node.id}' but has no throwing event in the loaded models.", + ) + } } } - - private fun thrownSignalNames(context: CrossModelValidationContext): Set { - return context.models - .flatMap { model -> model.signalEvents(EventDirection.THROW) } - .map { (_, signal) -> signal.name } - .toSet() - } - - private fun ProcessModel.signalEvents( - direction: EventDirection, - ): List> { - return flowNodes - .mapNotNull { node -> node.signalEvent()?.let { node to it } } - .filter { (_, signal) -> signal.direction == direction } - } - - private fun FlowNodeDefinition.signalEvent(): FlowNodeProperties.SignalEvent? { - return properties as? FlowNodeProperties.SignalEvent - } } diff --git a/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UnreferencedRootElementRule.kt b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UnreferencedRootElementRule.kt new file mode 100644 index 00000000..f896d3c1 --- /dev/null +++ b/bpmn-to-code-core/src/main/kotlin/io/miragon/bpmn/domain/validation/rules/UnreferencedRootElementRule.kt @@ -0,0 +1,59 @@ +package io.miragon.bpmn.domain.validation.rules + +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.shared.RootElementDefinition +import io.miragon.bpmn.domain.validation.SingleModelValidationRule +import io.miragon.bpmn.domain.validation.model.Severity +import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext +import io.miragon.bpmn.domain.validation.model.ValidationViolation + +/** + * Flags `bpmn:Definitions` root elements — messages, signals, errors, escalations — that no flow node + * references. + * + * Modelers leave these behind: deleting the event that used a message does not delete the message + * itself. The declaration is still valid BPMN, so this is a warning rather than an error, but it does + * have consequences. The generated Process API gets a constant nothing correlates to, and the JSON + * registry gets an entry no `…Ref` points at — both of which read as "this process handles that + * message" when it does not. + */ +class UnreferencedRootElementRule : SingleModelValidationRule { + + override val id = "unreferenced-root-element" + override val severity = Severity.WARN + + override fun validate(context: SingleModelValidationContext): List { + val model = context.model + val referenced = model.referencedDefinitionIds() + return model.definitions.run { + unreferenced(messages, referenced, "Message") + + unreferenced(signals, referenced, "Signal") + + unreferenced(errors, referenced, "Error") + + unreferenced(escalations, referenced, "Escalation") + }.map { (kind, element) -> violation(model, kind, element) } + } + + private fun unreferenced( + elements: List, + referenced: Set, + kind: String, + ): List> { + return elements.filterNot { it.id in referenced }.map { kind to it } + } + + private fun violation( + model: ProcessModel, + kind: String, + element: RootElementDefinition, + ): ValidationViolation { + return ValidationViolation( + ruleId = id, + severity = severity, + elementId = element.id, + processId = model.processId, + message = "$kind '${element.id}' is declared but no element references it. " + + "It still produces a constant in the generated API — remove it from the BPMN file " + + "if it is left over from an earlier version of the model.", + ) + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/inbound/ValidateBpmnFilesystemPluginTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/inbound/ValidateBpmnFilesystemPluginTest.kt index 57ecbbd5..05ac1fb0 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/inbound/ValidateBpmnFilesystemPluginTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/inbound/ValidateBpmnFilesystemPluginTest.kt @@ -2,8 +2,8 @@ package io.miragon.bpmn.adapter.inbound import io.miragon.bpmn.application.port.inbound.ValidateBpmnFromFilesystemUseCase import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.validation.model.ValidationConfig import io.miragon.bpmn.domain.validation.ValidationResult +import io.miragon.bpmn.domain.validation.model.ValidationConfig import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/CodeGenerationAdapterTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/CodeGenerationAdapterTest.kt index d349b9ce..c023dd4e 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/CodeGenerationAdapterTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/CodeGenerationAdapterTest.kt @@ -2,7 +2,7 @@ package io.miragon.bpmn.adapter.outbound.codegen import io.miragon.bpmn.domain.GeneratedApiFile import io.miragon.bpmn.domain.shared.OutputLanguage -import io.miragon.bpmn.domain.testBpmnModelApi +import io.miragon.bpmn.domain.testProcessModelApi import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk @@ -22,7 +22,7 @@ class CodeGenerationAdapterTest { fun `generateCode delegates to the process api builder and returns its file`() { // given: a model API and a stubbed process builder response - val modelApi = testBpmnModelApi() + val modelApi = testProcessModelApi() val processFile = GeneratedApiFile( fileName = "TestApi.kt", packagePath = "packagePath", @@ -45,7 +45,7 @@ class CodeGenerationAdapterTest { fun `generateCode throws when output language is not supported`() { // given: a model API with an unsupported language - val modelApi = testBpmnModelApi(language = OutputLanguage.JAVA) + val modelApi = testProcessModelApi(language = OutputLanguage.JAVA) // when / then: an exception is thrown assertThatThrownBy { underTest.generateCode(modelApi) } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/JavaProcessApiBuilderTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/JavaProcessApiBuilderTest.kt index 9e23c045..98567723 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/JavaProcessApiBuilderTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/JavaProcessApiBuilderTest.kt @@ -1,18 +1,17 @@ package io.miragon.bpmn.adapter.outbound.codegen.builder +import com.sun.source.util.JavacTask import io.miragon.bpmn.domain.BpmnModelApi -import io.miragon.bpmn.domain.MergedBpmnModel -import io.miragon.bpmn.domain.MergedBpmnModel.VariantData +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.ProcessModel.Variant import io.miragon.bpmn.domain.shared.OutputLanguage import io.miragon.bpmn.domain.shared.ProcessEngine import io.miragon.bpmn.domain.shared.VariableDefinition import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.domain.testBpmnModelApi -import io.miragon.bpmn.domain.testSendNewsletterBpmnModel -import io.miragon.bpmn.domain.testSubscribeNewsletterBpmnModel -import com.sun.source.util.JavacTask -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test +import io.miragon.bpmn.domain.testProcessModelApi +import io.miragon.bpmn.domain.testSendNewsletterModel +import io.miragon.bpmn.domain.testSubscribeNewsletterModel +import io.miragon.bpmn.domain.withId import java.io.File import java.net.URI import javax.tools.Diagnostic @@ -20,6 +19,8 @@ import javax.tools.DiagnosticCollector import javax.tools.JavaFileObject import javax.tools.SimpleJavaFileObject import javax.tools.ToolProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test class JavaProcessApiBuilderTest { @@ -29,9 +30,9 @@ class JavaProcessApiBuilderTest { fun `buildApiFile generates correct process API file`() { // given: a BPMN model with custom service task implementations - val modelApi = testBpmnModelApi( + val modelApi = testProcessModelApi( packagePath = "de.emaarco.example", - model = testSubscribeNewsletterBpmnModel( + model = testSubscribeNewsletterModel( flowNodes = buildSubscribeNewsletterFlowNodes( confirmationMailImpl = "#{newsletterSendConfirmationMail}", welcomeMailImpl = "\${newsletterSendWelcomeMail}", @@ -58,10 +59,10 @@ class JavaProcessApiBuilderTest { fun `maps content of id to valid variable name format`() { // given: a model with flow nodes that have slashes in their names - val defaultModel = testSubscribeNewsletterBpmnModel() - val modifiedNodes = defaultModel.flowNodes.map { it.copy(id = it.getName().replace("_", "-")) } - val modelApi = testBpmnModelApi( - model = testSubscribeNewsletterBpmnModel(flowNodes = modifiedNodes), + val defaultModel = testSubscribeNewsletterModel() + val modifiedNodes = defaultModel.flowNodes.map { it.withId(it.getName().replace("_", "-")) } + val modelApi = testProcessModelApi( + model = testSubscribeNewsletterModel(flowNodes = modifiedNodes), packagePath = "de.emaarco.example" ) @@ -77,16 +78,13 @@ class JavaProcessApiBuilderTest { fun `buildApiFile generates variant-scoped Flows and Relations for merged model`() { // given: a merged model with a single variant - val send = testSendNewsletterBpmnModel(variantName = "send") - val merged = MergedBpmnModel( + val send = testSendNewsletterModel(variantName = "send") + val merged = ProcessModel( processId = send.processId, flowNodes = send.flowNodes, - messages = send.messages, - signals = send.signals, - errors = send.errors, - escalations = send.escalations, + definitions = send.definitions, variants = listOf( - VariantData("send", send.sequenceFlows, send.flowNodes), + Variant("send", send.flowNodes, send.sequenceFlows), ), ) val modelApi = BpmnModelApi(merged, OutputLanguage.JAVA, "de.emaarco.example", ProcessEngine.ZEEBE) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/KotlinProcessApiBuilderTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/KotlinProcessApiBuilderTest.kt index 6a1ab8b0..4a978fc6 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/KotlinProcessApiBuilderTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/KotlinProcessApiBuilderTest.kt @@ -1,16 +1,17 @@ package io.miragon.bpmn.adapter.outbound.codegen.builder import io.miragon.bpmn.domain.BpmnModelApi -import io.miragon.bpmn.domain.MergedBpmnModel -import io.miragon.bpmn.domain.MergedBpmnModel.VariantData +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.ProcessModel.Variant import io.miragon.bpmn.domain.shared.OutputLanguage import io.miragon.bpmn.domain.shared.ProcessEngine +import io.miragon.bpmn.domain.shared.RootElementDefinition import io.miragon.bpmn.domain.shared.VariableDefinition import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.domain.testBpmnModelApi -import io.miragon.bpmn.domain.testSendNewsletterBpmnModel -import io.miragon.bpmn.domain.testSubscribeNewsletterBpmnModel - +import io.miragon.bpmn.domain.testProcessModelApi +import io.miragon.bpmn.domain.testSendNewsletterModel +import io.miragon.bpmn.domain.testSubscribeNewsletterModel +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.jetbrains.kotlin.K1Deprecation import org.jetbrains.kotlin.cli.common.messages.MessageCollector @@ -23,7 +24,6 @@ import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.psi.KtPsiFactory import org.jetbrains.kotlin.psi.KtTreeVisitorVoid import org.junit.jupiter.api.Test -import java.io.File class KotlinProcessApiBuilderTest { @@ -33,9 +33,9 @@ class KotlinProcessApiBuilderTest { fun `buildApiFile generates correct process API file`() { // given: a BPMN model with custom service task implementations - val modelApi = testBpmnModelApi( + val modelApi = testProcessModelApi( packagePath = "de.emaarco.example", - model = testSubscribeNewsletterBpmnModel( + model = testSubscribeNewsletterModel( flowNodes = buildSubscribeNewsletterFlowNodes( confirmationMailImpl = "#{newsletterSendConfirmationMail}", welcomeMailImpl = "\${newsletterSendWelcomeMail}", @@ -68,16 +68,13 @@ class KotlinProcessApiBuilderTest { fun `buildApiFile generates variant-scoped Flows and Relations for merged model`() { // given: a merged model with a single variant - val send = testSendNewsletterBpmnModel(variantName = "send") - val merged = MergedBpmnModel( + val send = testSendNewsletterModel(variantName = "send") + val merged = ProcessModel( processId = send.processId, flowNodes = send.flowNodes, - messages = send.messages, - signals = send.signals, - errors = send.errors, - escalations = send.escalations, + definitions = send.definitions, variants = listOf( - VariantData("send", send.sequenceFlows, send.flowNodes), + Variant("send", send.flowNodes, send.sequenceFlows), ), ) val modelApi = BpmnModelApi(merged, OutputLanguage.KOTLIN, "de.emaarco.example", ProcessEngine.ZEEBE) @@ -91,6 +88,26 @@ class KotlinProcessApiBuilderTest { assertKotlinSyntaxValid(result.content) } + @Test + fun `buildApiFile emits one constant for root elements that share a name`() { + + // given: two bpmn:Message root elements with the same name and their own ids — the domain keeps + // both so that every messageRef resolves, but they normalise to a single constant + val model = testSubscribeNewsletterModel( + messages = listOf( + RootElementDefinition.Message(id = "Message_1", name = "Message_FormSubmitted"), + RootElementDefinition.Message(id = "Message_2", name = "Message_FormSubmitted"), + ), + ) + + // when + val result = underTest.buildApiFile(testProcessModelApi(model = model)) + + // then: a duplicate property would not compile, so the collapsing has to happen before emitting + assertThat(result.content.split("val MESSAGE_FORM_SUBMITTED").size - 1).isEqualTo(1) + assertKotlinSyntaxValid(result.content) + } + companion object { @OptIn(K1Deprecation::class) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/NewsletterFlowNodes.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/NewsletterFlowNodes.kt index 478bf159..f1441996 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/NewsletterFlowNodes.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/codegen/builder/NewsletterFlowNodes.kt @@ -1,21 +1,20 @@ package io.miragon.bpmn.adapter.outbound.codegen.builder -import io.miragon.bpmn.domain.shared.SubProcessKind -import io.miragon.bpmn.domain.shared.BpmnNodeType +import io.miragon.bpmn.domain.jobWorkerTask +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance import io.miragon.bpmn.domain.shared.EventShape -import io.miragon.bpmn.domain.shared.EventDefinitionType +import io.miragon.bpmn.domain.shared.FlowNodeDefinition import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.MessageReference +import io.miragon.bpmn.domain.shared.SequenceFlowDefinition +import io.miragon.bpmn.domain.shared.SubProcessKind import io.miragon.bpmn.domain.shared.TaskKind -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_VALUE_KEY -import io.miragon.bpmn.domain.shared.TimerDefinition +import io.miragon.bpmn.domain.shared.TimerType import io.miragon.bpmn.domain.shared.VariableDefinition import io.miragon.bpmn.domain.shared.VariableDirection +@Suppress("LongMethod") internal fun buildSubscribeNewsletterFlowNodes( confirmationMailImpl: String, welcomeMailImpl: String, @@ -23,155 +22,160 @@ internal fun buildSubscribeNewsletterFlowNodes( notifyCommunityImpl: String, extraVariables: List = emptyList(), ) = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Activity.CallActivity( id = "CallActivity_AbortRegistration", - nodeType = BpmnNodeType.Activity.CallActivity, - properties = FlowNodeProperties.CallActivity( - CallActivityDefinition( - id = "CallActivity_AbortRegistration", - calledElement = "abort-registration", - mappings = listOf( - CallActivityMapping(direction = VariableDirection.INPUT, source = "subscriptionId", target = "childSubscriptionId"), - CallActivityMapping(direction = VariableDirection.INPUT, sourceExpression = "\${reasonCode}", target = "childReasonCode"), - CallActivityMapping(direction = VariableDirection.OUTPUT, source = "childAbortResult", target = "abortResult"), - ), + definition = CallActivityDefinition( + id = "CallActivity_AbortRegistration", + calledElement = "abort-registration", + mappings = listOf( + CallActivityDefinition.Mapping(direction = VariableDirection.INPUT, source = "subscriptionId", target = "childSubscriptionId"), + CallActivityDefinition.Mapping(direction = VariableDirection.INPUT, sourceExpression = "\${reasonCode}", target = "childReasonCode"), + CallActivityDefinition.Mapping(direction = VariableDirection.OUTPUT, source = "childAbortResult", target = "abortResult"), ), ), variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)), - previousElements = listOf("Timer_After3Days"), - followingElements = listOf("CompensationEndEvent_RegistrationAborted"), + incoming = listOf("Flow_1l1lj4m"), + outgoing = listOf("Flow_1bsb8no"), ), - FlowNodeDefinition( - id = "Activity_ConfirmRegistration", - displayName = "Confirm registration", - nodeType = BpmnNodeType.Activity.Task(TaskKind.RECEIVE), - attachedElements = listOf("Timer_EveryDay"), - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_SendConfirmationMail"), - followingElements = listOf("EndEvent_SubscriptionConfirmed"), - ), - FlowNodeDefinition( - id = "Activity_SendConfirmationMail", - nodeType = BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_SendConfirmationMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to confirmationMailImpl))), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)) + extraVariables, - parentId = "SubProcess_Confirmation", - previousElements = listOf("StartEvent_RequestReceived", "Timer_EveryDay"), - followingElements = listOf("Activity_ConfirmRegistration"), - ), - FlowNodeDefinition( + jobWorkerTask( id = "Activity_SendWelcomeMail", - nodeType = BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_SendWelcomeMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to welcomeMailImpl))), + jobType = welcomeMailImpl, + incoming = listOf("Flow_16hub0n"), + outgoing = listOf("Flow_1i7hjid"), variables = listOf( VariableDefinition("subscriptionId", VariableDirection.INPUT), VariableDefinition("subscriptionId", VariableDirection.OUTPUT), ), - previousElements = listOf("Gateway_SplitNotifications"), - followingElements = listOf("Gateway_JoinNotifications"), ), - FlowNodeDefinition( + jobWorkerTask( id = "Activity_NotifyCommunity", - nodeType = BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_NotifyCommunity", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to notifyCommunityImpl))), - previousElements = listOf("Gateway_SplitNotifications"), - followingElements = listOf("Gateway_JoinNotifications"), + jobType = notifyCommunityImpl, + incoming = listOf("Flow_1p5t47z"), + outgoing = listOf("Flow_1duwy83"), ), - FlowNodeDefinition( + FlowNodeDefinition.Gateway( id = "Gateway_SplitNotifications", - nodeType = BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("SubProcess_Confirmation"), - followingElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity"), + kind = GatewayKind.PARALLEL, + incoming = listOf("Flow_09cuvzp"), + outgoing = listOf("Flow_16hub0n", "Flow_1p5t47z"), ), - FlowNodeDefinition( + FlowNodeDefinition.Gateway( id = "Gateway_JoinNotifications", - nodeType = BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity"), - followingElements = listOf("EndEvent_RegistrationCompleted"), + kind = GatewayKind.PARALLEL, + incoming = listOf("Flow_1i7hjid", "Flow_1duwy83"), + outgoing = listOf("Flow_1862jd8"), ), - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "CompensationEndEvent_RegistrationAborted", - nodeType = BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.COMPENSATION), - previousElements = listOf("CallActivity_AbortRegistration"), + shape = EventShape.END_EVENT, + incoming = listOf("Flow_1bsb8no"), + eventDefinitions = listOf(EventDefinitionInstance.Compensation()), ), - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "CompensationEvent_OnSubscriptionCounter", - nodeType = BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.COMPENSATION), + shape = EventShape.BOUNDARY_EVENT, attachedToRef = "serviceTask_incrementSubscriptionCounter", interrupting = true, + eventDefinitions = listOf(EventDefinitionInstance.Compensation()), ), - FlowNodeDefinition( + jobWorkerTask( id = "CompensationTask_DecrementSubscriptionCounter", - nodeType = BpmnNodeType.Activity.Task(TaskKind.SERVICE), + jobType = "counterClass", ), - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "EndEvent_RegistrationCompleted", - nodeType = BpmnNodeType.Event(EventShape.END_EVENT), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("EndEvent_RegistrationCompleted", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to registrationCompletedImpl))), + shape = EventShape.END_EVENT, + incoming = listOf("Flow_1862jd8"), + implementation = io.miragon.bpmn.domain.shared.TaskImplementation.JobWorker(registrationCompletedImpl), variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), - previousElements = listOf("Gateway_JoinNotifications"), ), - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "EndEvent_RegistrationNotPossible", - nodeType = BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.SIGNAL), - previousElements = listOf("ErrorEvent_InvalidMail"), - ), - FlowNodeDefinition( - id = "EndEvent_SubscriptionConfirmed", - nodeType = BpmnNodeType.Event(EventShape.END_EVENT), - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_ConfirmRegistration"), + shape = EventShape.END_EVENT, + incoming = listOf("Flow_0i2ctuv"), + eventDefinitions = listOf( + EventDefinitionInstance.Signal("Signal_RegistrationNotPossible", "Signal_RegistrationNotPossible"), + ), ), - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "ErrorEvent_InvalidMail", - nodeType = BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.ERROR), + shape = EventShape.BOUNDARY_EVENT, attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("EndEvent_RegistrationNotPossible"), + outgoing = listOf("Flow_0i2ctuv"), + eventDefinitions = listOf(EventDefinitionInstance.Error("Error_InvalidMail", "Error_InvalidMail", "500")), ), - FlowNodeDefinition( + jobWorkerTask( id = "serviceTask_incrementSubscriptionCounter", - nodeType = BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("serviceTask_incrementSubscriptionCounter", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "counterClass"))), - attachedElements = listOf("CompensationEvent_OnSubscriptionCounter"), - previousElements = listOf("StartEvent_SubmitRegistrationForm"), - followingElements = listOf("SubProcess_Confirmation"), + jobType = "counterClass", + incoming = listOf("Flow_1csfyyz"), + outgoing = listOf("Flow_0zdmt0t"), + boundaryEventRefs = listOf("CompensationEvent_OnSubscriptionCounter"), ), - FlowNodeDefinition( - id = "StartEvent_RequestReceived", - nodeType = BpmnNodeType.Event(EventShape.START_EVENT), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail"), - ), - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "StartEvent_SubmitRegistrationForm", - nodeType = BpmnNodeType.Event(EventShape.START_EVENT, EventDefinitionType.MESSAGE), + shape = EventShape.START_EVENT, + outgoing = listOf("Flow_1csfyyz"), + eventDefinitions = listOf( + EventDefinitionInstance.Message(MessageReference("Message_FormSubmitted", "Message_FormSubmitted")), + ), variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), - followingElements = listOf("serviceTask_incrementSubscriptionCounter"), ), - FlowNodeDefinition( + FlowNodeDefinition.Activity.SubProcess( id = "SubProcess_Confirmation", - nodeType = BpmnNodeType.Activity.SubProcess(SubProcessKind.PLAIN), - attachedElements = listOf("ErrorEvent_InvalidMail", "Timer_After3Days"), - previousElements = listOf("serviceTask_incrementSubscriptionCounter"), - followingElements = listOf("Gateway_SplitNotifications"), + kind = SubProcessKind.PLAIN, + incoming = listOf("Flow_0zdmt0t"), + outgoing = listOf("Flow_09cuvzp"), + boundaryEventRefs = listOf("ErrorEvent_InvalidMail", "Timer_After3Days"), + flowNodes = listOf( + FlowNodeDefinition.Activity.Task( + id = "Activity_ConfirmRegistration", + kind = TaskKind.RECEIVE, + displayName = "Confirm registration", + incoming = listOf("Flow_1bckm43"), + outgoing = listOf("Flow_1cpwe57"), + boundaryEventRefs = listOf("Timer_EveryDay"), + ), + jobWorkerTask( + id = "Activity_SendConfirmationMail", + jobType = confirmationMailImpl, + incoming = listOf("Flow_05i3x1y", "Flow_0x4ewvb"), + outgoing = listOf("Flow_1bckm43"), + variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)) + extraVariables, + ), + FlowNodeDefinition.Event( + id = "EndEvent_SubscriptionConfirmed", + shape = EventShape.END_EVENT, + incoming = listOf("Flow_1cpwe57"), + ), + FlowNodeDefinition.Event( + id = "StartEvent_RequestReceived", + shape = EventShape.START_EVENT, + outgoing = listOf("Flow_05i3x1y"), + variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), + ), + FlowNodeDefinition.Event( + id = "Timer_EveryDay", + shape = EventShape.BOUNDARY_EVENT, + attachedToRef = "Activity_ConfirmRegistration", + interrupting = false, + outgoing = listOf("Flow_0x4ewvb"), + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DURATION, "PT1M")), + ), + ), + sequenceFlows = listOf( + SequenceFlowDefinition("Flow_05i3x1y", "StartEvent_RequestReceived", "Activity_SendConfirmationMail"), + SequenceFlowDefinition("Flow_0x4ewvb", "Timer_EveryDay", "Activity_SendConfirmationMail"), + SequenceFlowDefinition("Flow_1bckm43", "Activity_SendConfirmationMail", "Activity_ConfirmRegistration"), + SequenceFlowDefinition("Flow_1cpwe57", "Activity_ConfirmRegistration", "EndEvent_SubscriptionConfirmed"), + ), ), - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "Timer_After3Days", - nodeType = BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_After3Days", "Duration", "\${testVariable}")), + shape = EventShape.BOUNDARY_EVENT, attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("CallActivity_AbortRegistration"), - ), - FlowNodeDefinition( - id = "Timer_EveryDay", - nodeType = BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_EveryDay", "Duration", "PT1M")), - attachedToRef = "Activity_ConfirmRegistration", - interrupting = false, - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail"), + outgoing = listOf("Flow_1l1lj4m"), + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DURATION, "\${testVariable}")), ), ) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ActivityFacetExtractionTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ActivityFacetExtractionTest.kt new file mode 100644 index 00000000..8f1e99a1 --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ActivityFacetExtractionTest.kt @@ -0,0 +1,216 @@ +package io.miragon.bpmn.adapter.outbound.engine + +import io.miragon.bpmn.adapter.outbound.engine.dialect.CamundaDialect +import io.miragon.bpmn.adapter.outbound.engine.dialect.ZeebeDialect +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.IoMapping +import io.miragon.bpmn.domain.shared.MultiInstanceDefinition +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * Guards the two activity facets the v2 model introduced: multi-instance loop characteristics + * ([#73](https://github.com/Miragon/bpmn-to-code/issues/73)) and I/O mappings + * ([#74](https://github.com/Miragon/bpmn-to-code/issues/74)). + * + * Each engine spells them differently — `zeebe:loopCharacteristics` / `zeebe:ioMapping` versus + * `camunda:collection` / `camunda:inputOutput` — but they normalise onto the same domain shape. Only the + * expressions themselves stay engine-specific, because they are preserved verbatim (FEEL `=subscribers`, + * JUEL `${'$'}{subscribers}`, plain `subscribers`). + */ +class ActivityFacetExtractionTest { + + @Test + fun `zeebe extract reads multi-instance loop characteristics`() { + + // given + val model = extract(ProcessModelReader(ZeebeDialect()), "c8-send-newsletter") + + // then: isSequential comes from BPMN, the collection bindings from zeebe:loopCharacteristics + assertThat(model.multiInstanceOf("serviceTask_sendToSubscriber")).isEqualTo( + MultiInstanceDefinition( + sequential = true, + inputCollection = "=subscribers", + inputElement = "subscriber", + ) + ) + assertThat(model.multiInstanceOf("serviceTask_notifyAuthor")).isEqualTo( + MultiInstanceDefinition( + sequential = false, + inputCollection = "=authors", + inputElement = "author", + outputCollection = "results", + outputElement = "=result", + ) + ) + } + + @Test + fun `zeebe extract reads io mappings`() { + + // given + val model = extract(ProcessModelReader(ZeebeDialect()), "c8-send-newsletter") + + // then: zeebe:input and zeebe:output keep source and target verbatim + assertThat(model.ioMappingOf("serviceTask_loadSubscribers")).isEqualTo( + IoMapping( + outputs = listOf( + IoMapping.Parameter(target = "subscribers", source = "=subscribers"), + IoMapping.Parameter(target = "author", source = "=author"), + ) + ) + ) + assertThat(model.ioMappingOf("serviceTask_publishNewsletter")).isEqualTo( + IoMapping( + inputs = listOf( + IoMapping.Parameter(target = "method", source = "POST"), + IoMapping.Parameter(target = "url", source = "https://api.example.com/newsletter"), + ), + outputs = listOf(IoMapping.Parameter(target = "apiResponse", source = "=response")), + ) + ) + } + + @Test + fun `camunda 7 extract reads multi-instance loop characteristics`() { + + // given + val model = extract(ProcessModelReader(CamundaDialect(CAMUNDA_7_NAMESPACE)), "c7-send-newsletter") + + // then: camunda:collection and camunda:elementVariable normalise onto the same fields as Zeebe + assertThat(model.multiInstanceOf("serviceTask_sendToSubscriber")).isEqualTo( + MultiInstanceDefinition( + sequential = true, + inputCollection = "\${subscribers}", + inputElement = "subscriber", + ) + ) + assertThat(model.multiInstanceOf("serviceTask_notifyAuthor")).isEqualTo( + MultiInstanceDefinition( + sequential = false, + inputCollection = "\${authors}", + inputElement = "author", + ) + ) + } + + @Test + fun `camunda 7 extract reads io mappings`() { + + // given + val model = extract(ProcessModelReader(CamundaDialect(CAMUNDA_7_NAMESPACE)), "c7-send-newsletter") + + // then: the parameter name becomes the target, the element body the source + assertThat(model.ioMappingOf("serviceTask_loadSubscribers")).isEqualTo( + IoMapping( + outputs = listOf( + IoMapping.Parameter(target = "subscribers", source = "\${subscribers}"), + IoMapping.Parameter(target = "author", source = "\${author}"), + ) + ) + ) + assertThat(model.ioMappingOf("serviceTask_notifyAuthor")).isEqualTo( + IoMapping(inputs = listOf(IoMapping.Parameter(target = "test", source = "null"))) + ) + } + + @Test + fun `operaton extract reads multi-instance loop characteristics`() { + + // given + val model = extract(ProcessModelReader(CamundaDialect(OPERATON_NAMESPACE)), "operaton-send-newsletter") + + // then: the operaton namespace carries the identical vocabulary (ADR 010) + assertThat(model.multiInstanceOf("serviceTask_sendToSubscriber")).isEqualTo( + MultiInstanceDefinition( + sequential = true, + inputCollection = "subscribers", + inputElement = "subscriber", + ) + ) + assertThat(model.multiInstanceOf("serviceTask_notifyAuthor")).isEqualTo( + MultiInstanceDefinition( + sequential = false, + inputCollection = "authors", + inputElement = "author", + ) + ) + } + + @Test + fun `operaton extract reads io mappings`() { + + // given + val model = extract(ProcessModelReader(CamundaDialect(OPERATON_NAMESPACE)), "operaton-send-newsletter") + + // then + assertThat(model.ioMappingOf("serviceTask_loadSubscribers")).isEqualTo( + IoMapping( + outputs = listOf( + IoMapping.Parameter(target = "subscribers", source = "\${subscribers}"), + IoMapping.Parameter(target = "author", source = "\${author}"), + ) + ) + ) + } + + @Test + fun `an activity without loop characteristics or io mapping reports neither facet`() { + + // given: the same task in all three dialects, configured with neither facet + val models = listOf( + extract(ProcessModelReader(ZeebeDialect()), "c8-send-newsletter"), + extract(ProcessModelReader(CamundaDialect(CAMUNDA_7_NAMESPACE)), "c7-send-newsletter"), + extract(ProcessModelReader(CamundaDialect(OPERATON_NAMESPACE)), "operaton-send-newsletter"), + ) + + // then: absent facets stay null instead of collapsing to an empty object + models.forEach { model -> + assertThat(model.multiInstanceOf("serviceTask_loadSubscribers")).isNull() + assertThat(model.ioMappingOf("serviceTask_sendToSubscriber")).isNull() + } + } + + @Test + fun `the same logical loop normalises identically across engines`() { + + // given: the same process modelled for all three engines + val models = listOf( + extract(ProcessModelReader(ZeebeDialect()), "c8-send-newsletter"), + extract(ProcessModelReader(CamundaDialect(CAMUNDA_7_NAMESPACE)), "c7-send-newsletter"), + extract(ProcessModelReader(CamundaDialect(OPERATON_NAMESPACE)), "operaton-send-newsletter"), + ) + + // then: everything but the engine's own expression syntax agrees + assertThat(models.map { it.multiInstanceOf("serviceTask_sendToSubscriber")?.sequential }) + .containsOnly(true) + assertThat(models.map { it.multiInstanceOf("serviceTask_notifyAuthor")?.sequential }) + .containsOnly(false) + assertThat(models.map { it.multiInstanceOf("serviceTask_sendToSubscriber")?.inputElement }) + .containsOnly("subscriber") + assertThat(models.map { it.multiInstanceOf("serviceTask_notifyAuthor")?.inputElement }) + .containsOnly("author") + assertThat(models.map { it.ioMappingOf("serviceTask_loadSubscribers")?.outputs?.map { output -> output.target } }) + .containsOnly(listOf("subscribers", "author")) + } + + private fun extract(reader: ProcessModelReader, fixture: String): ProcessModel { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/$fixture.bpmn")) + return reader.read(File(resourceUrl.toURI()).readBytes()) + } + + private fun ProcessModel.activity(id: String): FlowNodeDefinition.Activity { + return allFlowNodes.single { it.id == id } as FlowNodeDefinition.Activity + } + + private fun ProcessModel.multiInstanceOf(id: String): MultiInstanceDefinition? = activity(id).multiInstance + + private fun ProcessModel.ioMappingOf(id: String): IoMapping? = activity(id).ioMapping + + private companion object { + const val CAMUNDA_7_NAMESPACE = "http://camunda.org/schema/1.0/bpmn" + const val OPERATON_NAMESPACE = "http://operaton.org/schema/1.0/bpmn" + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/Camunda7ExtractionTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/Camunda7ExtractionTest.kt new file mode 100644 index 00000000..294bb708 --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/Camunda7ExtractionTest.kt @@ -0,0 +1,407 @@ +package io.miragon.bpmn.adapter.outbound.engine + +import io.miragon.bpmn.adapter.outbound.engine.dialect.CamundaDialect +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.CompensationDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.ProcessEngine +import io.miragon.bpmn.domain.shared.SequenceFlowDefinition +import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.shared.TimerDefinition +import io.miragon.bpmn.domain.shared.TimerType +import io.miragon.bpmn.domain.shared.VariableDefinition +import io.miragon.bpmn.domain.shared.VariableDirection +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class Camunda7ExtractionTest { + + private val underTest = ProcessModelReader(CamundaDialect(CAMUNDA_7_NAMESPACE)) + + @Test + fun `extract returns valid ProcessModel`() { + + // given: the Camunda 7 newsletter BPMN file from classpath + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + + // when: extracting the model + val bpmnModel = underTest.read(file.readBytes()) + + fun node(id: String) = bpmnModel.allFlowNodes.single { it.id == id } + + // --- process-level metadata --- + assertThat(bpmnModel.processId).isEqualTo("newsletterSubscription") + assertThat(bpmnModel.variantName).isEqualTo("withApproval") + assertThat(bpmnModel.detectedEngine).isEqualTo(ProcessEngine.CAMUNDA_7) + assertThat(bpmnModel.isExecutable).isTrue() + + // --- root vs. nested scope --- + // the five nodes that lived in the sub-process (old parentId "SubProcess_Confirmation") must not be + // at the root, but must be reachable through allFlowNodes with their parent set on the graph + val nestedIds = listOf( + "Activity_ConfirmRegistration", + "Activity_SendConfirmationMail", + "EndEvent_SubscriptionConfirmed", + "StartEvent_RequestReceived", + "Timer_EveryDay", + ) + assertThat(bpmnModel.flowNodes.map { it.id }).containsExactlyInAnyOrder( + "CallActivity_AbortRegistration", + "Activity_SendWelcomeMail", + "Activity_NotifyCommunity", + "Gateway_SplitNotifications", + "Gateway_JoinNotifications", + "CompensationEndEvent_RegistrationAborted", + "CompensationEvent_OnSubscriptionCounter", + "CompensationTask_DecrementSubscriptionCounter", + "EndEvent_RegistrationCompleted", + "EndEvent_RegistrationNotPossible", + "ErrorEvent_InvalidMail", + "serviceTask_incrementSubscriptionCounter", + "StartEvent_SubmitRegistrationForm", + "SubProcess_Confirmation", + "Timer_After3Days", + ) + assertThat(bpmnModel.flowNodes.map { it.id }).doesNotContainAnyElementsOf(nestedIds) + assertThat(bpmnModel.allFlowNodes.map { it.id }).containsAll(nestedIds) + nestedIds.forEach { assertThat(bpmnModel.graph.parentIdOf(it)).isEqualTo("SubProcess_Confirmation") } + + // --- sub-process: kind and children --- + val subProcess = node("SubProcess_Confirmation") as FlowNodeDefinition.Activity.SubProcess + assertThat(subProcess.kind).isEqualTo(SubProcessKind.PLAIN) + assertThat(subProcess.flowNodes.map { it.id }).containsExactlyInAnyOrderElementsOf(nestedIds) + + // --- node kinds --- + assertThat((node("Activity_ConfirmRegistration") as FlowNodeDefinition.Activity.Task).kind).isEqualTo(TaskKind.USER) + val compensationHandler = node("CompensationTask_DecrementSubscriptionCounter") as FlowNodeDefinition.Activity.Task + assertThat(compensationHandler.kind).isEqualTo(TaskKind.SERVICE) + assertThat(compensationHandler.implementation).isEqualTo(TaskImplementation.DelegateExpression("counterClass")) + assertThat((node("Activity_SendWelcomeMail") as FlowNodeDefinition.Activity.Task).kind).isEqualTo(TaskKind.SERVICE) + assertThat((node("Gateway_SplitNotifications") as FlowNodeDefinition.Gateway).kind).isEqualTo(GatewayKind.PARALLEL) + assertThat((node("Gateway_JoinNotifications") as FlowNodeDefinition.Gateway).kind).isEqualTo(GatewayKind.PARALLEL) + assertThat(node("CallActivity_AbortRegistration")).isInstanceOf(FlowNodeDefinition.Activity.CallActivity::class.java) + + // --- service-task implementations (old IMPL_KIND -> type, IMPL_VALUE -> reference) --- + val implementations = bpmnModel.serviceTasks.associate { it.id to it.implementation } + assertThat(implementations["Activity_SendWelcomeMail"]).isEqualTo(TaskImplementation.DelegateExpression("\${newsletterSendWelcomeMail}")) + assertThat(implementations["Activity_SendConfirmationMail"]).isEqualTo(TaskImplementation.ExternalTask("#{newsletterSendConfirmationMail}")) + assertThat(implementations["EndEvent_RegistrationCompleted"]).isEqualTo(TaskImplementation.ExternalTask("newsletter.registrationCompleted")) + assertThat(implementations["serviceTask_incrementSubscriptionCounter"]).isEqualTo(TaskImplementation.DelegateExpression("counterClass")) + assertThat(implementations["Activity_NotifyCommunity"]).isEqualTo(TaskImplementation.DelegateExpression("\${newsletterNotifyCommunity}")) + + // --- event definitions --- + val timerAfter = node("Timer_After3Days") as FlowNodeDefinition.Event + assertThat(timerAfter.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(timerAfter.interrupting).isTrue() + assertThat(timerAfter.attachedToRef).isEqualTo("SubProcess_Confirmation") + assertThat(timerAfter.eventDefinitions).containsExactly(EventDefinitionInstance.Timer(TimerType.DURATION, "\${testVariable}")) + + val timerEveryDay = node("Timer_EveryDay") as FlowNodeDefinition.Event + assertThat(timerEveryDay.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(timerEveryDay.interrupting).isFalse() + assertThat(timerEveryDay.attachedToRef).isEqualTo("Activity_ConfirmRegistration") + assertThat(timerEveryDay.eventDefinitions).containsExactly(EventDefinitionInstance.Timer(TimerType.DURATION, "PT1M")) + + val submitForm = node("StartEvent_SubmitRegistrationForm") as FlowNodeDefinition.Event + assertThat(submitForm.shape).isEqualTo(EventShape.START_EVENT) + assertThat(submitForm.eventDefinitions.filterIsInstance().single().reference.messageName) + .isEqualTo("Message_FormSubmitted") + + val notPossible = node("EndEvent_RegistrationNotPossible") as FlowNodeDefinition.Event + assertThat(notPossible.shape).isEqualTo(EventShape.END_EVENT) + assertThat(notPossible.eventDefinitions.filterIsInstance().single().signalName) + .isEqualTo("Signal_RegistrationNotPossible") + + val invalidMail = node("ErrorEvent_InvalidMail") as FlowNodeDefinition.Event + assertThat(invalidMail.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(invalidMail.interrupting).isTrue() + assertThat(invalidMail.attachedToRef).isEqualTo("SubProcess_Confirmation") + val error = invalidMail.eventDefinitions.filterIsInstance().single() + assertThat(error.errorName).isEqualTo("Error_InvalidMail") + assertThat(error.errorCode).isEqualTo("500") + assertThat(bpmnModel.definitions.errors.map { it.getValue() }).contains("Error_InvalidMail" to "500") + + val abortedEnd = node("CompensationEndEvent_RegistrationAborted") as FlowNodeDefinition.Event + assertThat(abortedEnd.shape).isEqualTo(EventShape.END_EVENT) + assertThat(abortedEnd.eventDefinitions).anyMatch { it is EventDefinitionInstance.Compensation } + + val onCounter = node("CompensationEvent_OnSubscriptionCounter") as FlowNodeDefinition.Event + assertThat(onCounter.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(onCounter.interrupting).isTrue() + assertThat(onCounter.attachedToRef).isEqualTo("serviceTask_incrementSubscriptionCounter") + assertThat(onCounter.eventDefinitions).anyMatch { it is EventDefinitionInstance.Compensation } + + // --- derived timers --- + assertThat(bpmnModel.timers).containsExactlyInAnyOrder( + TimerDefinition("Timer_After3Days", TimerType.DURATION, "\${testVariable}"), + TimerDefinition("Timer_EveryDay", TimerType.DURATION, "PT1M"), + ) + + // --- derived compensations --- + assertThat(bpmnModel.compensations).containsExactlyInAnyOrder( + CompensationDefinition("CompensationEndEvent_RegistrationAborted", CompensationDefinition.Type.THROWING, activityRef = "serviceTask_incrementSubscriptionCounter", waitForCompletion = false), + CompensationDefinition("CompensationEvent_OnSubscriptionCounter", CompensationDefinition.Type.CATCHING, activityRef = null, waitForCompletion = false), + ) + + // --- call activity --- + val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } + assertThat(callActivity.hasCalledElement()).isTrue() + assertThat(callActivity.getValue()).isEqualTo("abort-registration") + assertThat(callActivity.inputMappings).containsExactlyInAnyOrder( + CallActivityDefinition.Mapping(VariableDirection.INPUT, source = "subscriptionId", target = "childSubscriptionId"), + CallActivityDefinition.Mapping(VariableDirection.INPUT, sourceExpression = "\${reasonCode}", target = "childReasonCode"), + ) + assertThat(callActivity.outputMappings).containsExactly( + CallActivityDefinition.Mapping(VariableDirection.OUTPUT, source = "childAbortResult", target = "abortResult"), + ) + assertThat(callActivity.propagateAllInputVariables).isNull() + assertThat(callActivity.propagateAllOutputVariables).isNull() + + // --- sequence flows: root scope vs. sub-process scope --- + val subProcessInternalFlows = listOf("Flow_05i3x1y", "Flow_0x4ewvb", "Flow_1bckm43", "Flow_1cpwe57") + assertThat(bpmnModel.sequenceFlows.map { it.id }).doesNotContainAnyElementsOf(subProcessInternalFlows) + assertThat(bpmnModel.sequenceFlows.map { it.id }).contains("Flow_09cuvzp", "Flow_0zdmt0t") + assertThat(subProcess.sequenceFlows.map { it.id }).containsExactlyInAnyOrderElementsOf(subProcessInternalFlows) + assertThat(bpmnModel.graph.allSequenceFlows).hasSize(15) + assertThat(bpmnModel.graph.allSequenceFlows).contains( + SequenceFlowDefinition("Flow_1bckm43", "Activity_SendConfirmationMail", "Activity_ConfirmRegistration"), + SequenceFlowDefinition("Flow_1l1lj4m", "Timer_After3Days", "CallActivity_AbortRegistration"), + ) + + // --- messages registry --- + assertThat(bpmnModel.definitions.messages.map { it.getValue() }).contains("Message_FormSubmitted") + + // --- boundary attachments --- + assertThat(bpmnModel.graph.attachedElementsOf(node("SubProcess_Confirmation"))) + .containsExactlyInAnyOrder("ErrorEvent_InvalidMail", "Timer_After3Days") + assertThat(bpmnModel.graph.attachedElementsOf(node("serviceTask_incrementSubscriptionCounter"))) + .containsExactly("CompensationEvent_OnSubscriptionCounter") + assertThat(bpmnModel.graph.attachedElementsOf(node("Activity_ConfirmRegistration"))) + .containsExactly("Timer_EveryDay") + + // --- node-to-node adjacency (derived through sequence flows) --- + assertThat(bpmnModel.graph.previousElementsOf(node("CallActivity_AbortRegistration"))).containsExactly("Timer_After3Days") + assertThat(bpmnModel.graph.followingElementsOf(node("CallActivity_AbortRegistration"))).containsExactly("CompensationEndEvent_RegistrationAborted") + assertThat(bpmnModel.graph.previousElementsOf(node("SubProcess_Confirmation"))).containsExactly("serviceTask_incrementSubscriptionCounter") + assertThat(bpmnModel.graph.followingElementsOf(node("Gateway_SplitNotifications"))) + .containsExactlyInAnyOrder("Activity_SendWelcomeMail", "Activity_NotifyCommunity") + } + + @Test + fun `extract captures call-activity input and output mapping targets`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) + val bpmnModel = underTest.read(File(resourceUrl.toURI()).readBytes()) + val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } + assertThat(callActivity.inputMappings).containsExactlyInAnyOrder( + CallActivityDefinition.Mapping(VariableDirection.INPUT, source = "subscriptionId", target = "childSubscriptionId"), + CallActivityDefinition.Mapping(VariableDirection.INPUT, sourceExpression = "\${reasonCode}", target = "childReasonCode"), + ) + assertThat(callActivity.outputMappings).containsExactly( + CallActivityDefinition.Mapping(VariableDirection.OUTPUT, source = "childAbortResult", target = "abortResult"), + ) + } + + @Test + fun `extract returns variantName from process-level extension properties`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variantName).isEqualTo("withApproval") + } + + @Test + fun `extract returns null variantName when not specified`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variantName).isNull() + } + + @Test + fun `extract returns additionalInputVariables and additionalOutputVariables from camunda properties`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-additional-variables.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variables).containsExactlyInAnyOrder( + VariableDefinition("orderId", VariableDirection.INPUT, "\${orderId}"), + VariableDefinition("orderId", VariableDirection.OUTPUT, "\${orderId}"), + VariableDefinition("orderId", VariableDirection.INPUT), + VariableDefinition("orderId", VariableDirection.OUTPUT), + VariableDefinition("customerEmail", VariableDirection.OUTPUT), + VariableDefinition("amount", VariableDirection.OUTPUT), + VariableDefinition("shipmentId", VariableDirection.OUTPUT), + VariableDefinition("cancellationReason", VariableDirection.INPUT), + VariableDefinition("retryCount", VariableDirection.INPUT), + ) + } + + @Test + fun `extract preserves direction when the same variable name is both input and output on one element`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-additional-variables.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + val activity = bpmnModel.flowNodes.single { it.id == "Activity_ProcessOrder" } + assertThat(activity.variables).contains( + VariableDefinition("orderId", VariableDirection.INPUT, "\${orderId}"), + VariableDefinition("orderId", VariableDirection.OUTPUT, "\${orderId}"), + ) + } + + @Test + fun `extract returns additionalInputVariables for non-interrupting message start event in event subprocess`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-additional-variables.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + // StartEvent_OrderCancelled is nested inside an event sub-process, so it lives under allFlowNodes + val startEvent = bpmnModel.allFlowNodes.single { it.id == "StartEvent_OrderCancelled" } + assertThat(startEvent.variables).containsExactlyInAnyOrder( + VariableDefinition("cancellationReason", VariableDirection.INPUT), + VariableDefinition("retryCount", VariableDirection.INPUT), + ) + } + + @Test + fun `extract returns multi-instance variables`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variables).containsExactlyInAnyOrder( + VariableDefinition("test", VariableDirection.INPUT, "null"), + VariableDefinition("authors", VariableDirection.INPUT, "\${authors}"), + VariableDefinition("author", VariableDirection.INPUT, "author"), + VariableDefinition("author", VariableDirection.OUTPUT, "\${author}"), + VariableDefinition("subscribers", VariableDirection.INPUT, "\${subscribers}"), + VariableDefinition("subscribers", VariableDirection.OUTPUT, "\${subscribers}"), + VariableDefinition("subscriber", VariableDirection.INPUT, "subscriber"), + ) + } + + @Test + fun `extract detects event subprocess type and extracts escalations`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + + val eventSubProcess = bpmnModel.flowNodes.single { it.id == "eventSubProcess_errorHandling" } + assertThat(eventSubProcess).isInstanceOf(FlowNodeDefinition.Activity.SubProcess::class.java) + assertThat((eventSubProcess as FlowNodeDefinition.Activity.SubProcess).kind).isEqualTo(SubProcessKind.EVENT) + + // the event subprocess start event carries the isInterrupting flag; a regular start event has none. + // event_mailRejected is nested in the event sub-process, so it lives under allFlowNodes. + val mailRejected = bpmnModel.allFlowNodes.single { it.id == "event_mailRejected" } as FlowNodeDefinition.Event + assertThat(mailRejected.interrupting).isTrue() + val editionCreated = bpmnModel.allFlowNodes.single { it.id == "startEvent_editionCreated" } as FlowNodeDefinition.Event + assertThat(editionCreated.interrupting).isNull() + + // both escalation end events reference the same bpmn:Escalation root element, so the registry — now + // keyed by that root element — holds a single entry (name-to-code via getValue()). + assertThat(bpmnModel.definitions.escalations.map { it.getValue() }).containsExactly("escalation_notifySupport" to "200") + listOf("escalationEndEvent_nofitySupport", "escalationEndEvent_nofitySupportAfterRepeatedError").forEach { id -> + val event = bpmnModel.allFlowNodes.single { it.id == id } as FlowNodeDefinition.Event + val escalation = event.eventDefinitions.filterIsInstance().single() + assertThat(escalation.escalationName).isEqualTo("escalation_notifySupport") + assertThat(escalation.escalationCode).isEqualTo("200") + } + } + + @Test + fun `extract marks default sequence flow correctly`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + + val flowsById = bpmnModel.sequenceFlows.associateBy { it.id } + assertThat(flowsById["Flow_1jogut0"]).isEqualTo( + SequenceFlowDefinition("Flow_1jogut0", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", flowName = "Yes", isDefault = true) + ) + assertThat(flowsById["Flow_1gsz7wd"]).isEqualTo( + SequenceFlowDefinition("Flow_1gsz7wd", "gateway_hasSubscribers", "endEvent_noSubscribers", flowName = "No", conditionExpression = "\${subscribers.size() > 0}") + ) + } + + @Test + fun `extract captures propagate-all and keeps named mappings alongside variables=all`() { + val xml = """ + + + + + + + + + + + + + """.trimIndent() + + val bpmnModel = underTest.read(xml.toByteArray()) + + val callActivity = bpmnModel.callActivities.single() + assertThat(callActivity.propagateAllInputVariables).isTrue() + assertThat(callActivity.propagateAllOutputVariables).isTrue() + assertThat(callActivity.inputMappings).containsExactly( + CallActivityDefinition.Mapping(VariableDirection.INPUT, source = "orderId", target = "businessKey") + ) + } + + @Test + fun `extract leaves propagate-all null when variables=all is not declared`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } + assertThat(callActivity.propagateAllInputVariables).isNull() + assertThat(callActivity.propagateAllOutputVariables).isNull() + } + + @Test + fun `extract marks a process with isExecutable false as non-executable`() { + val file = File(requireNotNull(javaClass.getResource("/bpmn/c7-non-executable.bpmn")).toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.isExecutable).isFalse() + } + + @Test + fun `extract marks a process with isExecutable true as executable`() { + val file = File(requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")).toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.isExecutable).isTrue() + } + + @Test + fun `extract treats an absent isExecutable attribute as executable`() { + val file = File(requireNotNull(javaClass.getResource("/bpmn/c7-no-executable-attr.bpmn")).toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.isExecutable).isTrue() + } + + @Test + fun `extract keeps root elements that no flow node references`() { + // given: the fixture declares Message_SubscriptionConfirmed but no element points at it + val file = File(requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")).toURI()) + + // when + val bpmnModel = underTest.read(file.readBytes()) + + // then: the model mirrors the file rather than silently dropping the declaration — + // UnreferencedRootElementRule is what reports it + assertThat(bpmnModel.definitions.messages.map { it.getValue() }) + .containsExactlyInAnyOrder("Message_FormSubmitted", "Message_SubscriptionConfirmed") + assertThat(bpmnModel.referencedDefinitionIds()).doesNotContain("Message_36dkcng") + } + + private companion object { + const val CAMUNDA_7_NAMESPACE = "http://camunda.org/schema/1.0/bpmn" + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ExtractBpmnAdapterTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ExtractBpmnAdapterTest.kt index 7263d51f..befabd6f 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ExtractBpmnAdapterTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ExtractBpmnAdapterTest.kt @@ -1,70 +1,79 @@ package io.miragon.bpmn.adapter.outbound.engine -import io.miragon.bpmn.adapter.outbound.engine.extractor.EngineSpecificExtractor import io.miragon.bpmn.domain.BpmnResource import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify +import io.miragon.bpmn.domain.shared.TaskImplementation +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test -import java.io.File class ExtractBpmnAdapterTest { - private val extractor = mockk(relaxed = true) - private val underTest = ExtractBpmnAdapter( - extractors = mapOf(ProcessEngine.ZEEBE to extractor) - ) + private val underTest = ExtractBpmnAdapter() @Test - fun `extract returns model using the correct extractor`() { - - // given: a dummy BPMN resource and a stubbed extractor - val expectedModel = testBpmnModel(processId = "dummyProcess") - val tempFile = File.createTempFile("dummy", ".bpmn").apply { deleteOnExit() } - val bpmnResource = BpmnResource( - fileName = "dummy.bpmn", - content = tempFile.readBytes(), - ) - every { extractor.extract(any()) } returns expectedModel - - // when: extracting with a supported engine + fun `extract reads the model with the dialect registered for the engine`() { + + // given: the Camunda 8 newsletter model + val bpmnResource = classpathResource("c8-subscribe-newsletter.bpmn") + + // when: extracting for Zeebe val result = underTest.extract(bpmnFile = bpmnResource, engine = ProcessEngine.ZEEBE) - // then: the extractor is called and the model is returned - verify { extractor.extract(any()) } - assertThat(result).isEqualTo(expectedModel) + // then: the model carries the job-worker implementations only the Zeebe dialect produces + assertThat(result.processId).isEqualTo("newsletterSubscription") + assertThat(result.serviceTasks.map { it.implementation }) + .contains(TaskImplementation.JobWorker("newsletter.sendWelcomeMail")) } @Test - fun `extract throws when no extractor is registered for the engine`() { + fun `extract throws when no dialect is registered for the engine`() { - // given: a resource targeting an engine with no registered extractor - val tempFile = File.createTempFile("dummy", ".bpmn").apply { deleteOnExit() } - val bpmnResource = BpmnResource( - fileName = "dummy.bpmn", - content = tempFile.readBytes(), - ) + // given: an adapter that only knows Zeebe + val zeebeOnly = ExtractBpmnAdapter(dialects = ExtractBpmnAdapter.dialects.filterKeys { it == ProcessEngine.ZEEBE }) + val bpmnResource = classpathResource("c7-subscribe-newsletter.bpmn") // when / then: an exception is thrown - assertThatThrownBy { underTest.extract(bpmnFile = bpmnResource, engine = ProcessEngine.CAMUNDA_7) } + assertThatThrownBy { zeebeOnly.extract(bpmnFile = bpmnResource, engine = ProcessEngine.CAMUNDA_7) } + .isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `extract names the offending file when the model cannot be read`() { + + // given: a well-formed BPMN file that declares no process + val bpmnResource = BpmnResource(fileName = "no-process.bpmn", content = DEFINITIONS_WITHOUT_PROCESS.toByteArray()) + + // when / then: the failure is wrapped and points at the file + assertThatThrownBy { underTest.extract(bpmnFile = bpmnResource, engine = ProcessEngine.ZEEBE) } .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("no-process.bpmn") } @Test - fun `extract wraps extractor exception in RuntimeException`() { + fun `a malformed file is reported with its name, not as a security violation`() { - // given: an extractor that throws during parsing - val tempFile = File.createTempFile("invalid", ".bpmn").apply { deleteOnExit() } - val bpmnResource = BpmnResource(fileName = "invalid.bpmn", content = tempFile.readBytes()) - every { extractor.extract(any()) } throws IllegalArgumentException("invalid BPMN content") + // given: a truncated BPMN file + val bpmnResource = BpmnResource(fileName = "truncated.bpmn", content = " + + + """.trimIndent() } } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/NormalisedExtensionTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/NormalisedExtensionTest.kt new file mode 100644 index 00000000..0663207f --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/NormalisedExtensionTest.kt @@ -0,0 +1,155 @@ +package io.miragon.bpmn.adapter.outbound.engine + +import io.miragon.bpmn.adapter.outbound.engine.dialect.CamundaDialect +import io.miragon.bpmn.adapter.outbound.engine.dialect.ZeebeDialect +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.TaskImplementation +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * `extensions` is the lossless escape hatch for engine XML we do **not** normalise (ADR 018, layer 3). + * Re-emitting what a dialect already read into a typed field would state the same fact twice, so each + * dialect declares the elements it reads in full and those are left out. + */ +class NormalisedExtensionTest { + + private companion object { + const val CAMUNDA_7_NAMESPACE = "http://camunda.org/schema/1.0/bpmn" + + val TASK_HEADERS_BPMN = """ + + + + + + + + + + + + + """.trimIndent() + + val TWO_IMPLEMENTATIONS_BPMN = """ + + + + + + """.trimIndent() + } + + @Test + fun `zeebe extensions leave out the elements the dialect reads in full`() { + + // given: the Camunda 8 model, whose nodes carry ioMapping, taskDefinition and calledElement + val model = extract(ZeebeDialect(), "c8-subscribe-newsletter") + + // when: looking at every extension the nodes kept + val types = model.allFlowNodes.flatMap { node -> node.extensions.map { it.type } } + + // then: none of them restates a field the dialect already normalised + assertThat(types).doesNotContain("zeebe:ioMapping", "zeebe:taskDefinition", "zeebe:calledElement") + } + + @Test + fun `the normalised fields still carry that information`() { + + // given: the same model + val model = extract(ZeebeDialect(), "c8-subscribe-newsletter") + + // then: what was left out of extensions is present in typed form, so nothing was lost + val task = model.allFlowNodes.single { it.id == "Activity_SendConfirmationMail" } + as FlowNodeDefinition.Activity.Task + assertThat(task.implementation?.reference).isEqualTo("newsletter.sendConfirmationMail") + assertThat(task.ioMapping?.inputs?.map { it.target }).contains("subscriptionId") + + val callActivity = model.allFlowNodes.single { it.id == "CallActivity_AbortRegistration" } + as FlowNodeDefinition.Activity.CallActivity + assertThat(callActivity.definition.getValue()).isEqualTo("abort-registration") + } + + @Test + fun `zeebe extensions keep an element the dialect does not read`() { + + // given: a task carrying zeebe:taskHeaders, which has no normalised counterpart + val model = ProcessModelReader(ZeebeDialect()).read(TASK_HEADERS_BPMN.toByteArray()) + + // when: reading the task's extensions + val extensions = model.allFlowNodes.single { it.id == "Task_1" }.extensions + + // then: the escape hatch still works — the raw element survives with its children + val headers = extensions.single { it.type == "zeebe:taskHeaders" } + assertThat(headers.children.single().attributes) + .containsEntry("key", "resultVariable") + .containsEntry("value", "order") + } + + @Test + fun `camunda extensions keep inputOutput because the dialect only reads part of it`() { + + // given: the Camunda 7 model + val model = extract(CamundaDialect(CAMUNDA_7_NAMESPACE), "c7-subscribe-newsletter") + + // when: looking at the extensions + val types = model.allFlowNodes.flatMap { node -> node.extensions.map { it.type } } + + // then: camunda:inputOutput stays — a nested camunda:script or camunda:map is not read into + // IoMapping, so dropping the raw element would lose information + assertThat(types).contains("camunda:inputOutput") + } + + @Test + fun `engine attributes leave out the one the dialect read`() { + + // given: the Camunda 7 model, whose compensation handler carries camunda:delegateExpression + val model = extract(CamundaDialect(CAMUNDA_7_NAMESPACE), "c7-subscribe-newsletter") + + // when: looking at the node that has it + val handler = model.allFlowNodes.single { it.id == "CompensationTask_DecrementSubscriptionCounter" } + as FlowNodeDefinition.Activity.Task + + // then: the attribute is reported once, as a typed implementation + assertThat(handler.implementation).isEqualTo(TaskImplementation.DelegateExpression("counterClass")) + assertThat(handler.engineAttributes).doesNotContainKey("camunda:delegateExpression") + } + + @Test + fun `engine attributes keep the ones the dialect does not read`() { + + // given: the Camunda 7 model + val model = extract(CamundaDialect(CAMUNDA_7_NAMESPACE), "c7-subscribe-newsletter") + + // when: looking at a node with execution attributes beyond the implementation + val keys = model.allFlowNodes.flatMap { it.engineAttributes.keys } + + // then: async and exclusive have no typed counterpart, so they stay + assertThat(keys).contains("camunda:asyncBefore", "camunda:exclusive") + } + + @Test + fun `an implementation attribute that lost the precedence race is still reported`() { + + // given: a task declaring both camunda:topic and camunda:class — only the topic wins + val model = ProcessModelReader(CamundaDialect(CAMUNDA_7_NAMESPACE)).read(TWO_IMPLEMENTATIONS_BPMN.toByteArray()) + val task = model.allFlowNodes.single { it.id == "Task_1" } as FlowNodeDefinition.Activity.Task + + // then: the loser is not silently dropped — the raw layer is where it stays reachable + assertThat(task.implementation).isEqualTo(TaskImplementation.ExternalTask("some-topic")) + assertThat(task.engineAttributes).containsEntry("camunda:class", "com.example.Handler") + assertThat(task.engineAttributes).doesNotContainKey("camunda:topic") + } + + private fun extract(dialect: io.miragon.bpmn.adapter.outbound.engine.dialect.EngineDialect, fixture: String): ProcessModel { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/$fixture.bpmn")) + return ProcessModelReader(dialect).read(File(resourceUrl.toURI()).readBytes()) + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/OperatonExtractionTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/OperatonExtractionTest.kt new file mode 100644 index 00000000..c9891add --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/OperatonExtractionTest.kt @@ -0,0 +1,318 @@ +package io.miragon.bpmn.adapter.outbound.engine + +import io.miragon.bpmn.adapter.outbound.engine.dialect.CamundaDialect +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.CompensationDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.ProcessEngine +import io.miragon.bpmn.domain.shared.SequenceFlowDefinition +import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.shared.TimerDefinition +import io.miragon.bpmn.domain.shared.TimerType +import io.miragon.bpmn.domain.shared.VariableDefinition +import io.miragon.bpmn.domain.shared.VariableDirection +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class OperatonExtractionTest { + + private val underTest = ProcessModelReader(CamundaDialect(OPERATON_NAMESPACE)) + + @Test + fun `extract returns valid ProcessModel with operaton namespace`() { + + // given: the Operaton newsletter BPMN file from classpath + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-subscribe-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + + // when: extracting the model + val bpmnModel = underTest.read(file.readBytes()) + + fun node(id: String) = bpmnModel.allFlowNodes.single { it.id == id } + + // --- process-level metadata --- + assertThat(bpmnModel.processId).isEqualTo("newsletterSubscription") + assertThat(bpmnModel.variantName).isEqualTo("withApproval") + assertThat(bpmnModel.detectedEngine).isEqualTo(ProcessEngine.OPERATON) + assertThat(bpmnModel.isExecutable).isTrue() + + // --- root vs. nested scope --- + // the five nodes that lived in the sub-process (old parentId "SubProcess_Confirmation") must not be + // at the root, but must be reachable through allFlowNodes with their parent set on the graph + val nestedIds = listOf( + "Activity_ConfirmRegistration", + "Activity_SendConfirmationMail", + "EndEvent_SubscriptionConfirmed", + "StartEvent_RequestReceived", + "Timer_EveryDay", + ) + assertThat(bpmnModel.flowNodes.map { it.id }).containsExactlyInAnyOrder( + "CallActivity_AbortRegistration", + "Activity_SendWelcomeMail", + "Activity_NotifyCommunity", + "Gateway_SplitNotifications", + "Gateway_JoinNotifications", + "CompensationEndEvent_RegistrationAborted", + "CompensationEvent_OnSubscriptionCounter", + "CompensationTask_DecrementSubscriptionCounter", + "EndEvent_RegistrationCompleted", + "EndEvent_RegistrationNotPossible", + "ErrorEvent_InvalidMail", + "serviceTask_incrementSubscriptionCounter", + "StartEvent_SubmitRegistrationForm", + "SubProcess_Confirmation", + "Timer_After3Days", + ) + assertThat(bpmnModel.flowNodes.map { it.id }).doesNotContainAnyElementsOf(nestedIds) + assertThat(bpmnModel.allFlowNodes.map { it.id }).containsAll(nestedIds) + nestedIds.forEach { assertThat(bpmnModel.graph.parentIdOf(it)).isEqualTo("SubProcess_Confirmation") } + + // --- sub-process: kind and children --- + val subProcess = node("SubProcess_Confirmation") as FlowNodeDefinition.Activity.SubProcess + assertThat(subProcess.kind).isEqualTo(SubProcessKind.PLAIN) + assertThat(subProcess.flowNodes.map { it.id }).containsExactlyInAnyOrderElementsOf(nestedIds) + + // --- node kinds --- + assertThat((node("Activity_ConfirmRegistration") as FlowNodeDefinition.Activity.Task).kind).isEqualTo(TaskKind.USER) + val compensationHandler = node("CompensationTask_DecrementSubscriptionCounter") as FlowNodeDefinition.Activity.Task + assertThat(compensationHandler.kind).isEqualTo(TaskKind.SERVICE) + assertThat(compensationHandler.implementation).isEqualTo(TaskImplementation.DelegateExpression("counterClass")) + assertThat((node("Activity_SendWelcomeMail") as FlowNodeDefinition.Activity.Task).kind).isEqualTo(TaskKind.SERVICE) + assertThat((node("Gateway_SplitNotifications") as FlowNodeDefinition.Gateway).kind).isEqualTo(GatewayKind.PARALLEL) + assertThat((node("Gateway_JoinNotifications") as FlowNodeDefinition.Gateway).kind).isEqualTo(GatewayKind.PARALLEL) + assertThat(node("CallActivity_AbortRegistration")).isInstanceOf(FlowNodeDefinition.Activity.CallActivity::class.java) + + // --- service-task implementations (old IMPL_KIND -> type, IMPL_VALUE -> reference) --- + val implementations = bpmnModel.serviceTasks.associate { it.id to it.implementation } + assertThat(implementations["Activity_SendWelcomeMail"]).isEqualTo(TaskImplementation.DelegateExpression("newsletter.sendWelcomeMail")) + assertThat(implementations["Activity_SendConfirmationMail"]).isEqualTo(TaskImplementation.ExternalTask("newsletter.sendConfirmationMail")) + assertThat(implementations["EndEvent_RegistrationCompleted"]).isEqualTo(TaskImplementation.ExternalTask("newsletter.registrationCompleted")) + assertThat(implementations["serviceTask_incrementSubscriptionCounter"]).isEqualTo(TaskImplementation.DelegateExpression("counterClass")) + assertThat(implementations["Activity_NotifyCommunity"]).isEqualTo(TaskImplementation.DelegateExpression("newsletter.notifyCommunity")) + + // --- event definitions --- + val timerAfter = node("Timer_After3Days") as FlowNodeDefinition.Event + assertThat(timerAfter.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(timerAfter.interrupting).isTrue() + assertThat(timerAfter.attachedToRef).isEqualTo("SubProcess_Confirmation") + assertThat(timerAfter.eventDefinitions).containsExactly(EventDefinitionInstance.Timer(TimerType.DURATION, "\${testVariable}")) + + val timerEveryDay = node("Timer_EveryDay") as FlowNodeDefinition.Event + assertThat(timerEveryDay.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(timerEveryDay.interrupting).isFalse() + assertThat(timerEveryDay.attachedToRef).isEqualTo("Activity_ConfirmRegistration") + assertThat(timerEveryDay.eventDefinitions).containsExactly(EventDefinitionInstance.Timer(TimerType.DURATION, "PT1M")) + + val submitForm = node("StartEvent_SubmitRegistrationForm") as FlowNodeDefinition.Event + assertThat(submitForm.shape).isEqualTo(EventShape.START_EVENT) + assertThat(submitForm.eventDefinitions.filterIsInstance().single().reference.messageName) + .isEqualTo("Message_FormSubmitted") + + val notPossible = node("EndEvent_RegistrationNotPossible") as FlowNodeDefinition.Event + assertThat(notPossible.shape).isEqualTo(EventShape.END_EVENT) + assertThat(notPossible.eventDefinitions.filterIsInstance().single().signalName) + .isEqualTo("Signal_RegistrationNotPossible") + + val invalidMail = node("ErrorEvent_InvalidMail") as FlowNodeDefinition.Event + assertThat(invalidMail.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(invalidMail.interrupting).isTrue() + assertThat(invalidMail.attachedToRef).isEqualTo("SubProcess_Confirmation") + val error = invalidMail.eventDefinitions.filterIsInstance().single() + assertThat(error.errorName).isEqualTo("Error_InvalidMail") + assertThat(error.errorCode).isEqualTo("500") + assertThat(bpmnModel.definitions.errors.map { it.getValue() }).contains("Error_InvalidMail" to "500") + + val abortedEnd = node("CompensationEndEvent_RegistrationAborted") as FlowNodeDefinition.Event + assertThat(abortedEnd.shape).isEqualTo(EventShape.END_EVENT) + assertThat(abortedEnd.eventDefinitions).anyMatch { it is EventDefinitionInstance.Compensation } + + val onCounter = node("CompensationEvent_OnSubscriptionCounter") as FlowNodeDefinition.Event + assertThat(onCounter.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(onCounter.interrupting).isTrue() + assertThat(onCounter.attachedToRef).isEqualTo("serviceTask_incrementSubscriptionCounter") + assertThat(onCounter.eventDefinitions).anyMatch { it is EventDefinitionInstance.Compensation } + + // --- derived timers --- + assertThat(bpmnModel.timers).containsExactlyInAnyOrder( + TimerDefinition("Timer_After3Days", TimerType.DURATION, "\${testVariable}"), + TimerDefinition("Timer_EveryDay", TimerType.DURATION, "PT1M"), + ) + + // --- derived compensations --- + assertThat(bpmnModel.compensations).containsExactlyInAnyOrder( + CompensationDefinition("CompensationEndEvent_RegistrationAborted", CompensationDefinition.Type.THROWING, activityRef = "serviceTask_incrementSubscriptionCounter", waitForCompletion = false), + CompensationDefinition("CompensationEvent_OnSubscriptionCounter", CompensationDefinition.Type.CATCHING, activityRef = null, waitForCompletion = false), + ) + + // --- call activity --- + val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } + assertThat(callActivity.hasCalledElement()).isTrue() + assertThat(callActivity.getValue()).isEqualTo("abort-registration") + assertThat(callActivity.inputMappings).containsExactlyInAnyOrder( + CallActivityDefinition.Mapping(VariableDirection.INPUT, source = "subscriptionId", target = "childSubscriptionId"), + CallActivityDefinition.Mapping(VariableDirection.INPUT, sourceExpression = "\${reasonCode}", target = "childReasonCode"), + ) + assertThat(callActivity.outputMappings).containsExactly( + CallActivityDefinition.Mapping(VariableDirection.OUTPUT, source = "childAbortResult", target = "abortResult"), + ) + assertThat(callActivity.propagateAllInputVariables).isNull() + assertThat(callActivity.propagateAllOutputVariables).isNull() + + // --- sequence flows: root scope vs. sub-process scope --- + val subProcessInternalFlows = listOf("Flow_05i3x1y", "Flow_0x4ewvb", "Flow_1bckm43", "Flow_1cpwe57") + assertThat(bpmnModel.sequenceFlows.map { it.id }).doesNotContainAnyElementsOf(subProcessInternalFlows) + assertThat(bpmnModel.sequenceFlows.map { it.id }).contains("Flow_09cuvzp", "Flow_0zdmt0t") + assertThat(subProcess.sequenceFlows.map { it.id }).containsExactlyInAnyOrderElementsOf(subProcessInternalFlows) + assertThat(bpmnModel.graph.allSequenceFlows).hasSize(15) + assertThat(bpmnModel.graph.allSequenceFlows).contains( + SequenceFlowDefinition("Flow_1bckm43", "Activity_SendConfirmationMail", "Activity_ConfirmRegistration"), + SequenceFlowDefinition("Flow_1l1lj4m", "Timer_After3Days", "CallActivity_AbortRegistration"), + ) + + // --- messages registry --- + assertThat(bpmnModel.definitions.messages.map { it.getValue() }).contains("Message_FormSubmitted") + + // --- boundary attachments --- + assertThat(bpmnModel.graph.attachedElementsOf(node("SubProcess_Confirmation"))) + .containsExactlyInAnyOrder("ErrorEvent_InvalidMail", "Timer_After3Days") + assertThat(bpmnModel.graph.attachedElementsOf(node("serviceTask_incrementSubscriptionCounter"))) + .containsExactly("CompensationEvent_OnSubscriptionCounter") + assertThat(bpmnModel.graph.attachedElementsOf(node("Activity_ConfirmRegistration"))) + .containsExactly("Timer_EveryDay") + + // --- node-to-node adjacency (derived through sequence flows) --- + assertThat(bpmnModel.graph.previousElementsOf(node("CallActivity_AbortRegistration"))).containsExactly("Timer_After3Days") + assertThat(bpmnModel.graph.followingElementsOf(node("CallActivity_AbortRegistration"))).containsExactly("CompensationEndEvent_RegistrationAborted") + assertThat(bpmnModel.graph.previousElementsOf(node("SubProcess_Confirmation"))).containsExactly("serviceTask_incrementSubscriptionCounter") + assertThat(bpmnModel.graph.followingElementsOf(node("Gateway_SplitNotifications"))) + .containsExactlyInAnyOrder("Activity_SendWelcomeMail", "Activity_NotifyCommunity") + } + + @Test + fun `extract returns variantName from process-level extension properties`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-subscribe-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variantName).isEqualTo("withApproval") + } + + @Test + fun `extract returns null variantName when not specified`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variantName).isNull() + } + + @Test + fun `extract returns additionalInputVariables and additionalOutputVariables from operaton properties`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-additional-variables.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variables).containsExactlyInAnyOrder( + VariableDefinition("orderId", VariableDirection.INPUT, "\${orderId}"), + VariableDefinition("orderId", VariableDirection.OUTPUT, "\${orderId}"), + VariableDefinition("orderId", VariableDirection.INPUT), + VariableDefinition("orderId", VariableDirection.OUTPUT), + VariableDefinition("customerEmail", VariableDirection.OUTPUT), + VariableDefinition("amount", VariableDirection.OUTPUT), + VariableDefinition("shipmentId", VariableDirection.OUTPUT), + VariableDefinition("cancellationReason", VariableDirection.INPUT), + VariableDefinition("retryCount", VariableDirection.INPUT), + ) + } + + @Test + fun `extract preserves direction when the same variable name is both input and output on one element`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-additional-variables.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + val activity = bpmnModel.allFlowNodes.single { it.id == "Activity_ProcessOrder" } + assertThat(activity.variables).contains( + VariableDefinition("orderId", VariableDirection.INPUT, "\${orderId}"), + VariableDefinition("orderId", VariableDirection.OUTPUT, "\${orderId}"), + ) + } + + @Test + fun `extract returns multi-instance variables`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variables).containsExactlyInAnyOrder( + VariableDefinition("authors", VariableDirection.INPUT, "authors"), + VariableDefinition("author", VariableDirection.INPUT, "author"), + VariableDefinition("author", VariableDirection.OUTPUT, "\${author}"), + VariableDefinition("subscribers", VariableDirection.INPUT, "subscribers"), + VariableDefinition("subscribers", VariableDirection.OUTPUT, "\${subscribers}"), + VariableDefinition("subscriber", VariableDirection.INPUT, "subscriber"), + ) + } + + @Test + fun `extract detects event subprocess type and extracts escalations`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + + val eventSubProcess = bpmnModel.flowNodes.single { it.id == "eventSubProcess_errorHandling" } + assertThat(eventSubProcess).isInstanceOf(FlowNodeDefinition.Activity.SubProcess::class.java) + assertThat((eventSubProcess as FlowNodeDefinition.Activity.SubProcess).kind).isEqualTo(SubProcessKind.EVENT) + + // the event subprocess start event carries the isInterrupting flag; a regular start event has none. + // event_mailRejected is nested in the event sub-process, so it lives under allFlowNodes. + val mailRejected = bpmnModel.allFlowNodes.single { it.id == "event_mailRejected" } as FlowNodeDefinition.Event + assertThat(mailRejected.interrupting).isTrue() + val editionCreated = bpmnModel.allFlowNodes.single { it.id == "startEvent_editionCreated" } as FlowNodeDefinition.Event + assertThat(editionCreated.interrupting).isNull() + + // both escalation end events reference the same bpmn:Escalation root element, so the registry — now + // keyed by that root element — holds a single entry (name-to-code via getValue()). + assertThat(bpmnModel.definitions.escalations.map { it.getValue() }).containsExactly("escalation_notifySupport" to "200") + listOf("escalationEndEvent_nofitySupport", "escalationEndEvent_nofitySupportAfterRepeatedError").forEach { id -> + val event = bpmnModel.allFlowNodes.single { it.id == id } as FlowNodeDefinition.Event + val escalation = event.eventDefinitions.filterIsInstance().single() + assertThat(escalation.escalationName).isEqualTo("escalation_notifySupport") + assertThat(escalation.escalationCode).isEqualTo("200") + } + } + + @Test + fun `extract marks default sequence flow correctly`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + + val flowsById = bpmnModel.sequenceFlows.associateBy { it.id } + assertThat(flowsById["Flow_1jogut0"]).isEqualTo( + SequenceFlowDefinition("Flow_1jogut0", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", flowName = "Yes", isDefault = true) + ) + assertThat(flowsById["Flow_1gsz7wd"]).isEqualTo( + SequenceFlowDefinition("Flow_1gsz7wd", "gateway_hasSubscribers", "endEvent_noSubscribers", flowName = "No", conditionExpression = "\${subscribers.size() > 0}") + ) + } + + @Test + fun `extract marks a process with isExecutable false as non-executable`() { + val file = File(requireNotNull(javaClass.getResource("/bpmn/operaton-non-executable.bpmn")).toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.isExecutable).isFalse() + } + + @Test + fun `extract marks a process with isExecutable true as executable`() { + val file = File(requireNotNull(javaClass.getResource("/bpmn/operaton-subscribe-newsletter.bpmn")).toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.isExecutable).isTrue() + } + + private companion object { + const val OPERATON_NAMESPACE = "http://operaton.org/schema/1.0/bpmn" + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ZeebeExtractionTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ZeebeExtractionTest.kt new file mode 100644 index 00000000..267c3c69 --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/ZeebeExtractionTest.kt @@ -0,0 +1,320 @@ +package io.miragon.bpmn.adapter.outbound.engine + +import io.miragon.bpmn.adapter.outbound.engine.dialect.ZeebeDialect +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.CompensationDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.ProcessEngine +import io.miragon.bpmn.domain.shared.SequenceFlowDefinition +import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.shared.TimerDefinition +import io.miragon.bpmn.domain.shared.TimerType +import io.miragon.bpmn.domain.shared.VariableDefinition +import io.miragon.bpmn.domain.shared.VariableDirection +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ZeebeExtractionTest { + + private val underTest = ProcessModelReader(ZeebeDialect()) + + @Test + fun `extract returns a fully populated ProcessModel`() { + + // given: the Camunda 8 / Zeebe newsletter BPMN file from classpath + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-subscribe-newsletter.bpmn")) + val bpmnModel = underTest.read(File(resourceUrl.toURI()).readBytes()) + + fun node(id: String): FlowNodeDefinition = bpmnModel.allFlowNodes.single { it.id == id } + fun event(id: String): FlowNodeDefinition.Event = node(id) as FlowNodeDefinition.Event + + // process-level metadata + assertThat(bpmnModel.processId).isEqualTo("newsletterSubscription") + assertThat(bpmnModel.variantName).isEqualTo("withApproval") + assertThat(bpmnModel.detectedEngine).isEqualTo(ProcessEngine.ZEEBE) + assertThat(bpmnModel.isExecutable).isTrue() + + // the root scope holds only root-level nodes; the confirmation sub-process owns its own children + assertThat(bpmnModel.flowNodes.mapNotNull { it.id }).containsExactlyInAnyOrder( + "CallActivity_AbortRegistration", + "Activity_SendWelcomeMail", + "Activity_NotifyCommunity", + "Gateway_SplitNotifications", + "Gateway_JoinNotifications", + "CompensationEndEvent_RegistrationAborted", + "CompensationEvent_OnSubscriptionCounter", + "CompensationTask_DecrementSubscriptionCounter", + "EndEvent_RegistrationCompleted", + "EndEvent_RegistrationNotPossible", + "ErrorEvent_InvalidMail", + "serviceTask_incrementSubscriptionCounter", + "StartEvent_SubmitRegistrationForm", + "SubProcess_Confirmation", + "Timer_After3Days", + ) + + // the sub-process nests its children and reports them through the flat view with the right parent + val subProcess = node("SubProcess_Confirmation") as FlowNodeDefinition.Activity.SubProcess + assertThat(subProcess.kind).isEqualTo(SubProcessKind.PLAIN) + assertThat(subProcess.flowNodes.mapNotNull { it.id }).containsExactlyInAnyOrder( + "Activity_ConfirmRegistration", + "Activity_SendConfirmationMail", + "EndEvent_SubscriptionConfirmed", + "StartEvent_RequestReceived", + "Timer_EveryDay", + ) + listOf( + "Activity_ConfirmRegistration", + "Activity_SendConfirmationMail", + "EndEvent_SubscriptionConfirmed", + "StartEvent_RequestReceived", + "Timer_EveryDay", + ).forEach { assertThat(bpmnModel.graph.parentIdOf(it)).isEqualTo("SubProcess_Confirmation") } + assertThat(bpmnModel.graph.parentIdOf("CallActivity_AbortRegistration")).isNull() + + // node kinds + assertThat((node("Gateway_SplitNotifications") as FlowNodeDefinition.Gateway).kind).isEqualTo(GatewayKind.PARALLEL) + assertThat((node("Gateway_JoinNotifications") as FlowNodeDefinition.Gateway).kind).isEqualTo(GatewayKind.PARALLEL) + val compensationHandler = node("CompensationTask_DecrementSubscriptionCounter") as FlowNodeDefinition.Activity.Task + assertThat(compensationHandler.kind).isEqualTo(TaskKind.SERVICE) + // a serviceTask, but the Zeebe fixture configures no zeebe:taskDefinition for it + assertThat(compensationHandler.implementation).isNull() + + // the receive task references its message directly (not through an event definition) + val confirmRegistration = node("Activity_ConfirmRegistration") as FlowNodeDefinition.Activity.Task + assertThat(confirmRegistration.kind).isEqualTo(TaskKind.RECEIVE) + assertThat(confirmRegistration.message?.messageName).isEqualTo("Message_SubscriptionConfirmed") + + // service-task-like implementations are all Zeebe job workers + val implementationsById = bpmnModel.serviceTasks.associate { it.id to it.implementation } + assertThat(implementationsById["Activity_SendConfirmationMail"]) + .isEqualTo(TaskImplementation.JobWorker("newsletter.sendConfirmationMail")) + assertThat(implementationsById["Activity_SendWelcomeMail"]) + .isEqualTo(TaskImplementation.JobWorker("newsletter.sendWelcomeMail")) + assertThat(implementationsById["Activity_NotifyCommunity"]) + .isEqualTo(TaskImplementation.JobWorker("newsletter.notifyCommunity")) + assertThat(implementationsById["serviceTask_incrementSubscriptionCounter"]) + .isEqualTo(TaskImplementation.JobWorker("newsletter.incrementCounter")) + assertThat(implementationsById["EndEvent_RegistrationCompleted"]) + .isEqualTo(TaskImplementation.JobWorker("newsletter.registrationCompleted")) + + // event definitions + assertThat(event("Timer_After3Days").eventDefinitions) + .containsExactly(EventDefinitionInstance.Timer(TimerType.DURATION, "=testVariable")) + assertThat(event("Timer_EveryDay").eventDefinitions) + .containsExactly(EventDefinitionInstance.Timer(TimerType.DURATION, "PT1M")) + val formMessage = event("StartEvent_SubmitRegistrationForm").eventDefinitions + .filterIsInstance().single() + assertThat(formMessage.reference.messageName).isEqualTo("Message_FormSubmitted") + assertThat(event("EndEvent_RegistrationNotPossible").eventDefinitions) + .containsExactly(EventDefinitionInstance.Signal("Signal_14g8ki5", "Signal_RegistrationNotPossible")) + assertThat(event("ErrorEvent_InvalidMail").eventDefinitions) + .containsExactly(EventDefinitionInstance.Error("Error_0uxgmyc", "Error_InvalidMail", "500")) + assertThat(event("CompensationEndEvent_RegistrationAborted").eventDefinitions) + .allMatch { it is EventDefinitionInstance.Compensation } + + // boundary events carry their attachment and cancel-activity flag + val errorBoundary = event("ErrorEvent_InvalidMail") + assertThat(errorBoundary.shape).isEqualTo(EventShape.BOUNDARY_EVENT) + assertThat(errorBoundary.attachedToRef).isEqualTo("SubProcess_Confirmation") + assertThat(errorBoundary.interrupting).isTrue() + val compensationBoundary = event("CompensationEvent_OnSubscriptionCounter") + assertThat(compensationBoundary.attachedToRef).isEqualTo("serviceTask_incrementSubscriptionCounter") + assertThat(compensationBoundary.interrupting).isTrue() + + // derived timer registry + assertThat(bpmnModel.timers).containsExactlyInAnyOrder( + TimerDefinition("Timer_After3Days", TimerType.DURATION, "=testVariable"), + TimerDefinition("Timer_EveryDay", TimerType.DURATION, "PT1M"), + ) + + // derived compensation registry + assertThat(bpmnModel.compensations).containsExactlyInAnyOrder( + CompensationDefinition( + "CompensationEndEvent_RegistrationAborted", + CompensationDefinition.Type.THROWING, + activityRef = "serviceTask_incrementSubscriptionCounter", + waitForCompletion = false, + ), + CompensationDefinition( + "CompensationEvent_OnSubscriptionCounter", + CompensationDefinition.Type.CATCHING, + activityRef = null, + waitForCompletion = false, + ), + ) + + // call activity target and mappings + val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } + assertThat(callActivity.hasCalledElement()).isTrue() + assertThat(callActivity.getValue()).isEqualTo("abort-registration") + assertThat(callActivity.inputMappings).containsExactly( + CallActivityDefinition.Mapping(VariableDirection.INPUT, source = "=subscriptionId", target = "subscriptionId"), + ) + assertThat(callActivity.outputMappings).isEmpty() + assertThat(callActivity.propagateAllInputVariables).isFalse() + assertThat(callActivity.propagateAllOutputVariables).isFalse() + + // message registry — the correlation key is declared on the bpmn:Message, so it lives here and not + // on each of the events referencing it + assertThat(bpmnModel.definitions.messages.map { it.getValue() }) + .containsExactlyInAnyOrder("Message_FormSubmitted", "Message_SubscriptionConfirmed") + assertThat(bpmnModel.definitions.messages.associate { it.getValue() to it.correlationKey }) + .containsEntry("Message_SubscriptionConfirmed", "=subscriptionId") + + // adjacency, resolved through the sequence flows + assertThat(bpmnModel.graph.previousElementsOf(node("Gateway_SplitNotifications"))) + .containsExactly("SubProcess_Confirmation") + assertThat(bpmnModel.graph.followingElementsOf(node("Gateway_SplitNotifications"))) + .containsExactlyInAnyOrder("Activity_SendWelcomeMail", "Activity_NotifyCommunity") + assertThat(bpmnModel.graph.attachedElementsOf(node("SubProcess_Confirmation"))) + .containsExactlyInAnyOrder("ErrorEvent_InvalidMail", "Timer_After3Days") + assertThat(bpmnModel.graph.attachedElementsOf(node("serviceTask_incrementSubscriptionCounter"))) + .containsExactly("CompensationEvent_OnSubscriptionCounter") + assertThat(bpmnModel.graph.attachedElementsOf(node("Activity_ConfirmRegistration"))) + .containsExactly("Timer_EveryDay") + + // root sequence flows exclude the four that belong to the confirmation sub-process + assertThat(bpmnModel.sequenceFlows.mapNotNull { it.id }) + .doesNotContain("Flow_05i3x1y", "Flow_0x4ewvb", "Flow_1bckm43", "Flow_1cpwe57") + assertThat(subProcess.sequenceFlows.mapNotNull { it.id }) + .containsExactlyInAnyOrder("Flow_05i3x1y", "Flow_0x4ewvb", "Flow_1bckm43", "Flow_1cpwe57") + assertThat(bpmnModel.graph.allSequenceFlows).hasSize(15) + } + + @Test + fun `extract returns variantName from process-level extension properties`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-subscribe-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variantName).isEqualTo("withApproval") + } + + @Test + fun `extract returns null variantName when not specified`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variantName).isNull() + } + + @Test + fun `extract captures call-activity io-mapping targets and propagate-all flags`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-subscribe-newsletter.bpmn")) + val bpmnModel = underTest.read(File(resourceUrl.toURI()).readBytes()) + val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } + assertThat(callActivity.inputMappings).containsExactly( + CallActivityDefinition.Mapping(VariableDirection.INPUT, source = "=subscriptionId", target = "subscriptionId"), + ) + assertThat(callActivity.outputMappings).isEmpty() + assertThat(callActivity.propagateAllInputVariables).isFalse() + assertThat(callActivity.propagateAllOutputVariables).isFalse() + } + + @Test + fun `extract returns multi-instance variables`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.variables).containsExactlyInAnyOrder( + VariableDefinition("test", VariableDirection.INPUT, "null"), + VariableDefinition("authors", VariableDirection.INPUT, "=authors"), + VariableDefinition("author", VariableDirection.INPUT, "author"), + VariableDefinition("author", VariableDirection.OUTPUT, "=author"), + VariableDefinition("subscribers", VariableDirection.INPUT, "=subscribers"), + VariableDefinition("subscribers", VariableDirection.OUTPUT, "=subscribers"), + VariableDefinition("subscriber", VariableDirection.INPUT, "subscriber"), + VariableDefinition("results", VariableDirection.OUTPUT, "results"), + VariableDefinition("result", VariableDirection.OUTPUT, "=result"), + VariableDefinition("method", VariableDirection.INPUT, "POST"), + VariableDefinition("url", VariableDirection.INPUT, "https://api.example.com/newsletter"), + VariableDefinition("apiResponse", VariableDirection.OUTPUT, "=response"), + ) + } + + @Test + fun `extract classifies element-template service tasks as connectors`() { + val file = File(requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")).toURI()) + val bpmnModel = underTest.read(file.readBytes()) + + val implementationsByReference = bpmnModel.serviceTasks.associate { it.implementation.reference to it.implementation } + assertThat(implementationsByReference["io.camunda:http-json:1"]) + .isInstanceOf(TaskImplementation.Connector::class.java) + assertThat(implementationsByReference["newsletter.loadSubscribers"]) + .isInstanceOf(TaskImplementation.JobWorker::class.java) + assertThat(implementationsByReference["newsletter.notifyAuthors"]) + .isInstanceOf(TaskImplementation.JobWorker::class.java) + } + + @Test + fun `extract detects event subprocess type and extracts escalations`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + + val eventSubProcess = bpmnModel.flowNodes.first { it.id == "eventSubProcess_errorHandling" } + assertThat(eventSubProcess).isInstanceOf(FlowNodeDefinition.Activity.SubProcess::class.java) + assertThat((eventSubProcess as FlowNodeDefinition.Activity.SubProcess).kind).isEqualTo(SubProcessKind.EVENT) + + // the event subprocess start event carries the isInterrupting flag, defaulting to true when unset + val mailRejected = bpmnModel.allFlowNodes.first { it.id == "event_mailRejected" } as FlowNodeDefinition.Event + assertThat(mailRejected.interrupting).isTrue() + // a regular (non-event-subprocess) start event has no interrupting flag + val editionCreated = bpmnModel.allFlowNodes.first { it.id == "startEvent_editionCreated" } as FlowNodeDefinition.Event + assertThat(editionCreated.interrupting).isNull() + + // both escalation events reference the same root escalation, so the registry deduplicates to one entry + assertThat(bpmnModel.definitions.escalations.map { it.getValue() }).containsExactly("escalation_notifySupport" to "200") + } + + @Test + fun `extract marks default sequence flow correctly`() { + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + val bpmnModel = underTest.read(file.readBytes()) + + val flowsById = bpmnModel.sequenceFlows.associateBy { it.id } + assertThat(flowsById["Flow_1jogut0"]).isEqualTo( + SequenceFlowDefinition("Flow_1jogut0", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", flowName = "Yes", isDefault = true) + ) + assertThat(flowsById["Flow_1gsz7wd"]).isEqualTo( + SequenceFlowDefinition("Flow_1gsz7wd", "gateway_hasSubscribers", "endEvent_noSubscribers", flowName = "No", conditionExpression = "=subscribers.size() > 0") + ) + } + + @Test + fun `extract stays tolerant and leaves a call activity without calledElement for later validation`() { + + // given: a Camunda 7 model with a call activity and no zeebe:calledElement + val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) + val file = File(resourceUrl.toURI()) + + // when: extracting the mismatched model + val bpmnModel = underTest.read(file.readBytes()) + + // then: extraction does not validate or fail here + val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } + assertThat(callActivity.hasCalledElement()).isFalse() + assertThat(bpmnModel.detectedEngine).isEqualTo(ProcessEngine.CAMUNDA_7) + } + + @Test + fun `extract marks a process with isExecutable false as non-executable`() { + val file = File(requireNotNull(javaClass.getResource("/bpmn/c8-non-executable.bpmn")).toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.isExecutable).isFalse() + } + + @Test + fun `extract marks a process with isExecutable true as executable`() { + val file = File(requireNotNull(javaClass.getResource("/bpmn/c8-subscribe-newsletter.bpmn")).toURI()) + val bpmnModel = underTest.read(file.readBytes()) + assertThat(bpmnModel.isExecutable).isTrue() + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ModelExtractorTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ModelExtractorTest.kt deleted file mode 100644 index ffc37a6c..00000000 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/Camunda7ModelExtractorTest.kt +++ /dev/null @@ -1,381 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -import io.miragon.bpmn.domain.shared.SubProcessKind -import io.miragon.bpmn.domain.shared.BpmnNodeType -import io.miragon.bpmn.domain.shared.EventShape -import io.miragon.bpmn.domain.shared.EventDefinitionType -import io.miragon.bpmn.domain.shared.GatewayKind -import io.miragon.bpmn.domain.shared.TaskKind -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping -import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.CompensationType -import io.miragon.bpmn.domain.shared.EscalationDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_AFTER_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_BEFORE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.EXCLUSIVE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.EventDirection -import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_KIND_KEY -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_VALUE_KEY -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition -import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testSubscribeNewsletterBpmnModel -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.io.File - -class Camunda7ModelExtractorTest { - - private val underTest = Camunda7ModelExtractor() - - @Test - fun `extract returns valid BpmnModel`() { - - // given: the Camunda 7 newsletter BPMN file from classpath - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - - // when: extracting the model - val bpmnModel = underTest.extract(file.readBytes()) - - // then: the model matches the expected structure - val c7ServiceTasks = listOf( - ServiceTaskDefinition("Activity_SendWelcomeMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "\${newsletterSendWelcomeMail}", IMPL_KIND_KEY to "DELEGATE_EXPRESSION")), - ServiceTaskDefinition("Activity_SendConfirmationMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "#{newsletterSendConfirmationMail}", IMPL_KIND_KEY to "EXTERNAL_TASK")), - ServiceTaskDefinition("EndEvent_RegistrationCompleted", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.registrationCompleted", IMPL_KIND_KEY to "EXTERNAL_TASK")), - ServiceTaskDefinition("serviceTask_incrementSubscriptionCounter", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "counterClass", IMPL_KIND_KEY to "DELEGATE_EXPRESSION")), - ServiceTaskDefinition("CompensationTask_DecrementSubscriptionCounter", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "counterClass", IMPL_KIND_KEY to "DELEGATE_EXPRESSION")), - ) - val c7ServiceTaskById = c7ServiceTasks.associateBy { it.id } - - assertThat(bpmnModel).usingRecursiveComparison().ignoringCollectionOrder().isEqualTo( - testSubscribeNewsletterBpmnModel( - variantName = "withApproval", - detectedEngine = ProcessEngine.CAMUNDA_7, - flowNodes = listOf( - FlowNodeDefinition("CallActivity_AbortRegistration", BpmnNodeType.Activity.CallActivity, - displayName = "Abort registration", - properties = FlowNodeProperties.CallActivity(CallActivityDefinition("CallActivity_AbortRegistration", "abort-registration", - mappings = listOf( - CallActivityMapping(VariableDirection.INPUT, source = "subscriptionId", target = "childSubscriptionId"), - CallActivityMapping(VariableDirection.INPUT, sourceExpression = "\${reasonCode}", target = "childReasonCode"), - CallActivityMapping(VariableDirection.OUTPUT, source = "childAbortResult", target = "abortResult"), - ))), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "subscriptionId"), VariableDefinition("reasonCode", VariableDirection.INPUT, "\${reasonCode}"), VariableDefinition("abortResult", VariableDirection.OUTPUT, "abortResult")), - previousElements = listOf("Timer_After3Days"), - followingElements = listOf("CompensationEndEvent_RegistrationAborted"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, ASYNC_AFTER_KEY to true)), - FlowNodeDefinition("Activity_ConfirmRegistration", BpmnNodeType.Activity.Task(TaskKind.USER), - displayName = "Confirm subscription", - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}")), - attachedElements = listOf("Timer_EveryDay"), - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_SendConfirmationMail"), - followingElements = listOf("EndEvent_SubscriptionConfirmed"), - engineSpecificProperties = mapOf(ASYNC_AFTER_KEY to true)), - FlowNodeDefinition("Activity_SendConfirmationMail", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Send confirmation mail", - properties = FlowNodeProperties.ServiceTask(c7ServiceTaskById["Activity_SendConfirmationMail"]!!), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}"), VariableDefinition("otherVariable", VariableDirection.INPUT, "dummy")), - parentId = "SubProcess_Confirmation", - previousElements = listOf("StartEvent_RequestReceived", "Timer_EveryDay"), - followingElements = listOf("Activity_ConfirmRegistration")), - FlowNodeDefinition("Activity_SendWelcomeMail", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Send Welcome-Mail", - properties = FlowNodeProperties.ServiceTask(c7ServiceTaskById["Activity_SendWelcomeMail"]!!), - variables = listOf( - VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}"), - VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "\${subscriptionId}"), - ), - previousElements = listOf("Gateway_SplitNotifications"), - followingElements = listOf("Gateway_JoinNotifications"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, ASYNC_AFTER_KEY to true, EXCLUSIVE_KEY to false)), - FlowNodeDefinition("Activity_NotifyCommunity", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Notify community", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_NotifyCommunity", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "\${newsletterNotifyCommunity}", IMPL_KIND_KEY to "DELEGATE_EXPRESSION"))), - previousElements = listOf("Gateway_SplitNotifications"), - followingElements = listOf("Gateway_JoinNotifications"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, ASYNC_AFTER_KEY to true, EXCLUSIVE_KEY to false)), - FlowNodeDefinition("Gateway_SplitNotifications", BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("SubProcess_Confirmation"), - followingElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity")), - FlowNodeDefinition("Gateway_JoinNotifications", BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity"), - followingElements = listOf("EndEvent_RegistrationCompleted")), - FlowNodeDefinition("CompensationEndEvent_RegistrationAborted", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.COMPENSATION), - displayName = "Registration aborted", - previousElements = listOf("CallActivity_AbortRegistration")), - FlowNodeDefinition("CompensationEvent_OnSubscriptionCounter", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.COMPENSATION), - displayName = "Registration aborted", - attachedToRef = "serviceTask_incrementSubscriptionCounter", interrupting = true, - engineSpecificProperties = mapOf(ASYNC_AFTER_KEY to true)), - FlowNodeDefinition("CompensationTask_DecrementSubscriptionCounter", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Decrement subscription counter", - properties = FlowNodeProperties.ServiceTask(c7ServiceTaskById["CompensationTask_DecrementSubscriptionCounter"]!!), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}"))), - FlowNodeDefinition("EndEvent_RegistrationCompleted", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.MESSAGE), - displayName = "Registration completed", - properties = FlowNodeProperties.ServiceTask(c7ServiceTaskById["EndEvent_RegistrationCompleted"]!!), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}")), - previousElements = listOf("Gateway_JoinNotifications")), - FlowNodeDefinition("EndEvent_RegistrationNotPossible", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.SIGNAL), - displayName = "Registration not possible", - properties = FlowNodeProperties.SignalEvent("Signal_RegistrationNotPossible", EventDirection.THROW), - previousElements = listOf("ErrorEvent_InvalidMail"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, EXCLUSIVE_KEY to false)), - FlowNodeDefinition("EndEvent_SubscriptionConfirmed", BpmnNodeType.Event(EventShape.END_EVENT), - displayName = "Subscription confirmed", - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_ConfirmRegistration")), - FlowNodeDefinition("ErrorEvent_InvalidMail", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.ERROR), - displayName = "Invalid Mail", - attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("EndEvent_RegistrationNotPossible")), - FlowNodeDefinition("serviceTask_incrementSubscriptionCounter", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Increment subscription counter", - properties = FlowNodeProperties.ServiceTask(c7ServiceTaskById["serviceTask_incrementSubscriptionCounter"]!!), - attachedElements = listOf("CompensationEvent_OnSubscriptionCounter"), - previousElements = listOf("StartEvent_SubmitRegistrationForm"), - followingElements = listOf("SubProcess_Confirmation")), - FlowNodeDefinition("StartEvent_RequestReceived", BpmnNodeType.Event(EventShape.START_EVENT), - displayName = "Subscription requested", - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "\${subscriptionId}")), - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true)), - FlowNodeDefinition("StartEvent_SubmitRegistrationForm", BpmnNodeType.Event(EventShape.START_EVENT, EventDefinitionType.MESSAGE), - displayName = "Submit newsletter form", - properties = FlowNodeProperties.MessageEvent("Message_FormSubmitted", EventDirection.CATCH), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "\${subscriptionId}")), - followingElements = listOf("serviceTask_incrementSubscriptionCounter")), - FlowNodeDefinition("SubProcess_Confirmation", BpmnNodeType.Activity.SubProcess(SubProcessKind.PLAIN), - displayName = "Subscription Confirmation", - attachedElements = listOf("ErrorEvent_InvalidMail", "Timer_After3Days"), - previousElements = listOf("serviceTask_incrementSubscriptionCounter"), - followingElements = listOf("Gateway_SplitNotifications")), - FlowNodeDefinition("Timer_After3Days", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - displayName = "After 3 days", - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_After3Days", "Duration", "\${testVariable}")), - attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("CallActivity_AbortRegistration")), - FlowNodeDefinition("Timer_EveryDay", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - displayName = "Every day", - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_EveryDay", "Duration", "PT1M")), - attachedToRef = "Activity_ConfirmRegistration", interrupting = false, - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail")), - ), - sequenceFlows = listOf( - SequenceFlowDefinition("Flow_05i3x1y", "StartEvent_RequestReceived", "Activity_SendConfirmationMail"), - SequenceFlowDefinition("Flow_09cuvzp", "SubProcess_Confirmation", "Gateway_SplitNotifications"), - SequenceFlowDefinition("Flow_0i2ctuv", "ErrorEvent_InvalidMail", "EndEvent_RegistrationNotPossible"), - SequenceFlowDefinition("Flow_0x4ewvb", "Timer_EveryDay", "Activity_SendConfirmationMail"), - SequenceFlowDefinition("Flow_0zdmt0t", "serviceTask_incrementSubscriptionCounter", "SubProcess_Confirmation"), - SequenceFlowDefinition("Flow_16hub0n", "Gateway_SplitNotifications", "Activity_SendWelcomeMail"), - SequenceFlowDefinition("Flow_1862jd8", "Gateway_JoinNotifications", "EndEvent_RegistrationCompleted"), - SequenceFlowDefinition("Flow_1bckm43", "Activity_SendConfirmationMail", "Activity_ConfirmRegistration"), - SequenceFlowDefinition("Flow_1bsb8no", "CallActivity_AbortRegistration", "CompensationEndEvent_RegistrationAborted"), - SequenceFlowDefinition("Flow_1cpwe57", "Activity_ConfirmRegistration", "EndEvent_SubscriptionConfirmed"), - SequenceFlowDefinition("Flow_1csfyyz", "StartEvent_SubmitRegistrationForm", "serviceTask_incrementSubscriptionCounter"), - SequenceFlowDefinition("Flow_1duwy83", "Activity_NotifyCommunity", "Gateway_JoinNotifications"), - SequenceFlowDefinition("Flow_1i7hjid", "Activity_SendWelcomeMail", "Gateway_JoinNotifications"), - SequenceFlowDefinition("Flow_1l1lj4m", "Timer_After3Days", "CallActivity_AbortRegistration"), - SequenceFlowDefinition("Flow_1p5t47z", "Gateway_SplitNotifications", "Activity_NotifyCommunity"), - ), - compensations = listOf( - CompensationDefinition("CompensationEndEvent_RegistrationAborted", CompensationType.THROWING, engineSpecificProperties = mapOf("activityRef" to "serviceTask_incrementSubscriptionCounter", "waitForCompletion" to false)), - CompensationDefinition("CompensationEvent_OnSubscriptionCounter", CompensationType.CATCHING, engineSpecificProperties = mapOf("waitForCompletion" to false)), - ), - ) - ) - } - - @Test - fun `extract captures call-activity input and output mapping targets`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) - val bpmnModel = underTest.extract(File(resourceUrl.toURI()).readBytes()) - val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } - assertThat(callActivity.inputMappings).containsExactlyInAnyOrder( - CallActivityMapping(VariableDirection.INPUT, source = "subscriptionId", target = "childSubscriptionId"), - CallActivityMapping(VariableDirection.INPUT, sourceExpression = "\${reasonCode}", target = "childReasonCode"), - ) - assertThat(callActivity.outputMappings).containsExactly( - CallActivityMapping(VariableDirection.OUTPUT, source = "childAbortResult", target = "abortResult"), - ) - } - - @Test - fun `extract returns variantName from process-level extension properties`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variantName).isEqualTo("withApproval") - } - - @Test - fun `extract returns null variantName when not specified`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variantName).isNull() - } - - @Test - fun `extract returns additionalInputVariables and additionalOutputVariables from camunda properties`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-additional-variables.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variables).containsExactlyInAnyOrder( - VariableDefinition("orderId", VariableDirection.INPUT, "\${orderId}"), - VariableDefinition("orderId", VariableDirection.OUTPUT, "\${orderId}"), - VariableDefinition("orderId", VariableDirection.INPUT), - VariableDefinition("orderId", VariableDirection.OUTPUT), - VariableDefinition("customerEmail", VariableDirection.OUTPUT), - VariableDefinition("amount", VariableDirection.OUTPUT), - VariableDefinition("shipmentId", VariableDirection.OUTPUT), - VariableDefinition("cancellationReason", VariableDirection.INPUT), - VariableDefinition("retryCount", VariableDirection.INPUT), - ) - } - - @Test - fun `extract preserves direction when the same variable name is both input and output on one element`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-additional-variables.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - val activity = bpmnModel.flowNodes.single { it.id == "Activity_ProcessOrder" } - assertThat(activity.variables).contains( - VariableDefinition("orderId", VariableDirection.INPUT, "\${orderId}"), - VariableDefinition("orderId", VariableDirection.OUTPUT, "\${orderId}"), - ) - } - - @Test - fun `extract returns additionalInputVariables for non-interrupting message start event in event subprocess`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-additional-variables.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - val startEvent = bpmnModel.flowNodes.single { it.id == "StartEvent_OrderCancelled" } - assertThat(startEvent.variables).containsExactlyInAnyOrder( - VariableDefinition("cancellationReason", VariableDirection.INPUT), - VariableDefinition("retryCount", VariableDirection.INPUT), - ) - } - - @Test - fun `extract returns multi-instance variables`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variables).containsExactlyInAnyOrder( - VariableDefinition("test", VariableDirection.INPUT, "null"), - VariableDefinition("authors", VariableDirection.INPUT, "\${authors}"), - VariableDefinition("author", VariableDirection.INPUT, "author"), - VariableDefinition("author", VariableDirection.OUTPUT, "\${author}"), - VariableDefinition("subscribers", VariableDirection.INPUT, "\${subscribers}"), - VariableDefinition("subscribers", VariableDirection.OUTPUT, "\${subscribers}"), - VariableDefinition("subscriber", VariableDirection.INPUT, "subscriber"), - ) - } - - @Test - fun `extract detects event subprocess type and extracts escalations`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - - val eventSubProcess = bpmnModel.flowNodes.first { it.id == "eventSubProcess_errorHandling" } - assertThat(eventSubProcess.nodeType).isEqualTo(BpmnNodeType.Activity.SubProcess(SubProcessKind.EVENT)) - - // the event subprocess start event carries the isInterrupting flag, defaulting to true when unset - assertThat(bpmnModel.flowNodes.first { it.id == "event_mailRejected" }.interrupting).isTrue() - // a regular (non-event-subprocess) start event has no interrupting flag - assertThat(bpmnModel.flowNodes.first { it.id == "startEvent_editionCreated" }.interrupting).isNull() - - assertThat(bpmnModel.escalations).containsExactlyInAnyOrder( - EscalationDefinition("escalationEndEvent_nofitySupport", "escalation_notifySupport", "200"), - EscalationDefinition("escalationEndEvent_nofitySupportAfterRepeatedError", "escalation_notifySupport", "200"), - ) - } - - @Test - fun `extract marks default sequence flow correctly`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - - val flowsById = bpmnModel.sequenceFlows.associateBy { it.id } - assertThat(flowsById["Flow_1jogut0"]).isEqualTo( - SequenceFlowDefinition("Flow_1jogut0", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", flowName = "Yes", isDefault = true) - ) - assertThat(flowsById["Flow_1gsz7wd"]).isEqualTo( - SequenceFlowDefinition("Flow_1gsz7wd", "gateway_hasSubscribers", "endEvent_noSubscribers", flowName = "No", conditionExpression = "\${subscribers.size() > 0}") - ) - } - - @Test - fun `extract captures propagate-all and keeps named mappings alongside variables=all`() { - val xml = """ - - - - - - - - - - - - - """.trimIndent() - - val bpmnModel = underTest.extract(xml.toByteArray()) - - val callActivity = bpmnModel.callActivities.single() - assertThat(callActivity.propagateAllInputVariables).isTrue() - assertThat(callActivity.propagateAllOutputVariables).isTrue() - assertThat(callActivity.inputMappings).containsExactly( - CallActivityMapping(VariableDirection.INPUT, source = "orderId", target = "businessKey") - ) - } - - @Test - fun `extract leaves propagate-all null when variables=all is not declared`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } - assertThat(callActivity.propagateAllInputVariables).isNull() - assertThat(callActivity.propagateAllOutputVariables).isNull() - } - - @Test - fun `extract marks a process with isExecutable false as non-executable`() { - val file = File(requireNotNull(javaClass.getResource("/bpmn/c7-non-executable.bpmn")).toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.isExecutable).isFalse() - } - - @Test - fun `extract marks a process with isExecutable true as executable`() { - val file = File(requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")).toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.isExecutable).isTrue() - } - - @Test - fun `extract treats an absent isExecutable attribute as executable`() { - val file = File(requireNotNull(javaClass.getResource("/bpmn/c7-no-executable-attr.bpmn")).toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.isExecutable).isTrue() - } -} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonModelExtractorTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonModelExtractorTest.kt deleted file mode 100644 index bc56cda4..00000000 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/OperatonModelExtractorTest.kt +++ /dev/null @@ -1,308 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -import io.miragon.bpmn.domain.shared.SubProcessKind -import io.miragon.bpmn.domain.shared.BpmnNodeType -import io.miragon.bpmn.domain.shared.EventShape -import io.miragon.bpmn.domain.shared.EventDefinitionType -import io.miragon.bpmn.domain.shared.GatewayKind -import io.miragon.bpmn.domain.shared.TaskKind -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping -import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.CompensationType -import io.miragon.bpmn.domain.shared.EscalationDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_AFTER_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_BEFORE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.EXCLUSIVE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.EventDirection -import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_KIND_KEY -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_VALUE_KEY -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition -import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testSubscribeNewsletterBpmnModel -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.io.File - -class OperatonModelExtractorTest { - - private val underTest = OperatonModelExtractor() - - @Test - fun `extract returns valid BpmnModel with operaton namespace`() { - - // given: the Operaton newsletter BPMN file from classpath - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-subscribe-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - - // when: extracting the model - val bpmnModel = underTest.extract(file.readBytes()) - - // then: the model matches the expected structure - val opServiceTasks = listOf( - ServiceTaskDefinition("Activity_SendWelcomeMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendWelcomeMail", IMPL_KIND_KEY to "DELEGATE_EXPRESSION")), - ServiceTaskDefinition("Activity_SendConfirmationMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendConfirmationMail", IMPL_KIND_KEY to "EXTERNAL_TASK")), - ServiceTaskDefinition("EndEvent_RegistrationCompleted", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.registrationCompleted", IMPL_KIND_KEY to "EXTERNAL_TASK")), - ServiceTaskDefinition("serviceTask_incrementSubscriptionCounter", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "counterClass", IMPL_KIND_KEY to "DELEGATE_EXPRESSION")), - ServiceTaskDefinition("CompensationTask_DecrementSubscriptionCounter", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "counterClass", IMPL_KIND_KEY to "DELEGATE_EXPRESSION")), - ) - val opServiceTaskById = opServiceTasks.associateBy { it.id } - - assertThat(bpmnModel).usingRecursiveComparison().ignoringCollectionOrder().isEqualTo( - testSubscribeNewsletterBpmnModel( - variantName = "withApproval", - detectedEngine = ProcessEngine.OPERATON, - flowNodes = listOf( - FlowNodeDefinition("CallActivity_AbortRegistration", BpmnNodeType.Activity.CallActivity, - displayName = "Abort registration", - properties = FlowNodeProperties.CallActivity(CallActivityDefinition("CallActivity_AbortRegistration", "abort-registration", - mappings = listOf( - CallActivityMapping(VariableDirection.INPUT, source = "subscriptionId", target = "childSubscriptionId"), - CallActivityMapping(VariableDirection.INPUT, sourceExpression = "\${reasonCode}", target = "childReasonCode"), - CallActivityMapping(VariableDirection.OUTPUT, source = "childAbortResult", target = "abortResult"), - ))), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "subscriptionId"), VariableDefinition("reasonCode", VariableDirection.INPUT, "\${reasonCode}"), VariableDefinition("abortResult", VariableDirection.OUTPUT, "abortResult")), - previousElements = listOf("Timer_After3Days"), - followingElements = listOf("CompensationEndEvent_RegistrationAborted"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, ASYNC_AFTER_KEY to true)), - FlowNodeDefinition("Activity_ConfirmRegistration", BpmnNodeType.Activity.Task(TaskKind.USER), - displayName = "Confirm subscription", - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}")), - attachedElements = listOf("Timer_EveryDay"), - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_SendConfirmationMail"), - followingElements = listOf("EndEvent_SubscriptionConfirmed"), - engineSpecificProperties = mapOf(ASYNC_AFTER_KEY to true)), - FlowNodeDefinition("Activity_SendConfirmationMail", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Send confirmation mail", - properties = FlowNodeProperties.ServiceTask(opServiceTaskById["Activity_SendConfirmationMail"]!!), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}"), VariableDefinition("otherVariable", VariableDirection.INPUT, "dummy")), - parentId = "SubProcess_Confirmation", - previousElements = listOf("StartEvent_RequestReceived", "Timer_EveryDay"), - followingElements = listOf("Activity_ConfirmRegistration")), - FlowNodeDefinition("Activity_SendWelcomeMail", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Send Welcome-Mail", - properties = FlowNodeProperties.ServiceTask(opServiceTaskById["Activity_SendWelcomeMail"]!!), - variables = listOf( - VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}"), - VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "\${subscriptionId}"), - ), - previousElements = listOf("Gateway_SplitNotifications"), - followingElements = listOf("Gateway_JoinNotifications"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, ASYNC_AFTER_KEY to true, EXCLUSIVE_KEY to false)), - FlowNodeDefinition("Activity_NotifyCommunity", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Notify community", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_NotifyCommunity", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.notifyCommunity", IMPL_KIND_KEY to "DELEGATE_EXPRESSION"))), - previousElements = listOf("Gateway_SplitNotifications"), - followingElements = listOf("Gateway_JoinNotifications"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, ASYNC_AFTER_KEY to true, EXCLUSIVE_KEY to false)), - FlowNodeDefinition("Gateway_SplitNotifications", BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("SubProcess_Confirmation"), - followingElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity")), - FlowNodeDefinition("Gateway_JoinNotifications", BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity"), - followingElements = listOf("EndEvent_RegistrationCompleted")), - FlowNodeDefinition("CompensationEndEvent_RegistrationAborted", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.COMPENSATION), - displayName = "Registration aborted", - previousElements = listOf("CallActivity_AbortRegistration")), - FlowNodeDefinition("CompensationEvent_OnSubscriptionCounter", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.COMPENSATION), - displayName = "Registration aborted", - attachedToRef = "serviceTask_incrementSubscriptionCounter", interrupting = true), - FlowNodeDefinition("CompensationTask_DecrementSubscriptionCounter", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Decrement subscription counter", - properties = FlowNodeProperties.ServiceTask(opServiceTaskById["CompensationTask_DecrementSubscriptionCounter"]!!), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}"))), - FlowNodeDefinition("EndEvent_RegistrationCompleted", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.MESSAGE), - displayName = "Registration completed", - properties = FlowNodeProperties.ServiceTask(opServiceTaskById["EndEvent_RegistrationCompleted"]!!), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "\${subscriptionId}")), - previousElements = listOf("Gateway_JoinNotifications")), - FlowNodeDefinition("EndEvent_RegistrationNotPossible", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.SIGNAL), - displayName = "Registration not possible", - properties = FlowNodeProperties.SignalEvent("Signal_RegistrationNotPossible", EventDirection.THROW), - previousElements = listOf("ErrorEvent_InvalidMail"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, EXCLUSIVE_KEY to false)), - FlowNodeDefinition("EndEvent_SubscriptionConfirmed", BpmnNodeType.Event(EventShape.END_EVENT), - displayName = "Subscription confirmed", - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_ConfirmRegistration")), - FlowNodeDefinition("ErrorEvent_InvalidMail", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.ERROR), - displayName = "Invalid Mail", - attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("EndEvent_RegistrationNotPossible")), - FlowNodeDefinition("serviceTask_incrementSubscriptionCounter", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Increment subscription counter", - properties = FlowNodeProperties.ServiceTask(opServiceTaskById["serviceTask_incrementSubscriptionCounter"]!!), - attachedElements = listOf("CompensationEvent_OnSubscriptionCounter"), - previousElements = listOf("StartEvent_SubmitRegistrationForm"), - followingElements = listOf("SubProcess_Confirmation")), - FlowNodeDefinition("StartEvent_RequestReceived", BpmnNodeType.Event(EventShape.START_EVENT), - displayName = "Subscription requested", - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "\${subscriptionId}")), - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true)), - FlowNodeDefinition("StartEvent_SubmitRegistrationForm", BpmnNodeType.Event(EventShape.START_EVENT, EventDefinitionType.MESSAGE), - displayName = "Submit newsletter form", - properties = FlowNodeProperties.MessageEvent("Message_FormSubmitted", EventDirection.CATCH), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "\${subscriptionId}")), - followingElements = listOf("serviceTask_incrementSubscriptionCounter")), - FlowNodeDefinition("SubProcess_Confirmation", BpmnNodeType.Activity.SubProcess(SubProcessKind.PLAIN), - displayName = "Subscription Confirmation", - attachedElements = listOf("ErrorEvent_InvalidMail", "Timer_After3Days"), - previousElements = listOf("serviceTask_incrementSubscriptionCounter"), - followingElements = listOf("Gateway_SplitNotifications")), - FlowNodeDefinition("Timer_After3Days", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - displayName = "After 3 days", - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_After3Days", "Duration", "\${testVariable}")), - attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("CallActivity_AbortRegistration")), - FlowNodeDefinition("Timer_EveryDay", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - displayName = "Every day", - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_EveryDay", "Duration", "PT1M")), - attachedToRef = "Activity_ConfirmRegistration", interrupting = false, - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail")), - ), - sequenceFlows = listOf( - SequenceFlowDefinition("Flow_05i3x1y", "StartEvent_RequestReceived", "Activity_SendConfirmationMail"), - SequenceFlowDefinition("Flow_09cuvzp", "SubProcess_Confirmation", "Gateway_SplitNotifications"), - SequenceFlowDefinition("Flow_0i2ctuv", "ErrorEvent_InvalidMail", "EndEvent_RegistrationNotPossible"), - SequenceFlowDefinition("Flow_0x4ewvb", "Timer_EveryDay", "Activity_SendConfirmationMail"), - SequenceFlowDefinition("Flow_0zdmt0t", "serviceTask_incrementSubscriptionCounter", "SubProcess_Confirmation"), - SequenceFlowDefinition("Flow_16hub0n", "Gateway_SplitNotifications", "Activity_SendWelcomeMail"), - SequenceFlowDefinition("Flow_1862jd8", "Gateway_JoinNotifications", "EndEvent_RegistrationCompleted"), - SequenceFlowDefinition("Flow_1bckm43", "Activity_SendConfirmationMail", "Activity_ConfirmRegistration"), - SequenceFlowDefinition("Flow_1bsb8no", "CallActivity_AbortRegistration", "CompensationEndEvent_RegistrationAborted"), - SequenceFlowDefinition("Flow_1cpwe57", "Activity_ConfirmRegistration", "EndEvent_SubscriptionConfirmed"), - SequenceFlowDefinition("Flow_1csfyyz", "StartEvent_SubmitRegistrationForm", "serviceTask_incrementSubscriptionCounter"), - SequenceFlowDefinition("Flow_1duwy83", "Activity_NotifyCommunity", "Gateway_JoinNotifications"), - SequenceFlowDefinition("Flow_1i7hjid", "Activity_SendWelcomeMail", "Gateway_JoinNotifications"), - SequenceFlowDefinition("Flow_1l1lj4m", "Timer_After3Days", "CallActivity_AbortRegistration"), - SequenceFlowDefinition("Flow_1p5t47z", "Gateway_SplitNotifications", "Activity_NotifyCommunity"), - ), - compensations = listOf( - CompensationDefinition("CompensationEndEvent_RegistrationAborted", CompensationType.THROWING, engineSpecificProperties = mapOf("activityRef" to "serviceTask_incrementSubscriptionCounter", "waitForCompletion" to false)), - CompensationDefinition("CompensationEvent_OnSubscriptionCounter", CompensationType.CATCHING, engineSpecificProperties = mapOf("waitForCompletion" to false)), - ), - ) - ) - } - - @Test - fun `extract returns variantName from process-level extension properties`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-subscribe-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variantName).isEqualTo("withApproval") - } - - @Test - fun `extract returns null variantName when not specified`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variantName).isNull() - } - - @Test - fun `extract returns additionalInputVariables and additionalOutputVariables from operaton properties`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-additional-variables.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variables).containsExactlyInAnyOrder( - VariableDefinition("orderId", VariableDirection.INPUT, "\${orderId}"), - VariableDefinition("orderId", VariableDirection.OUTPUT, "\${orderId}"), - VariableDefinition("orderId", VariableDirection.INPUT), - VariableDefinition("orderId", VariableDirection.OUTPUT), - VariableDefinition("customerEmail", VariableDirection.OUTPUT), - VariableDefinition("amount", VariableDirection.OUTPUT), - VariableDefinition("shipmentId", VariableDirection.OUTPUT), - VariableDefinition("cancellationReason", VariableDirection.INPUT), - VariableDefinition("retryCount", VariableDirection.INPUT), - ) - } - - @Test - fun `extract preserves direction when the same variable name is both input and output on one element`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-additional-variables.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - val activity = bpmnModel.flowNodes.single { it.id == "Activity_ProcessOrder" } - assertThat(activity.variables).contains( - VariableDefinition("orderId", VariableDirection.INPUT, "\${orderId}"), - VariableDefinition("orderId", VariableDirection.OUTPUT, "\${orderId}"), - ) - } - - @Test - fun `extract returns multi-instance variables`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variables).containsExactlyInAnyOrder( - VariableDefinition("authors", VariableDirection.INPUT, "authors"), - VariableDefinition("author", VariableDirection.INPUT, "author"), - VariableDefinition("author", VariableDirection.OUTPUT, "\${author}"), - VariableDefinition("subscribers", VariableDirection.INPUT, "subscribers"), - VariableDefinition("subscribers", VariableDirection.OUTPUT, "\${subscribers}"), - VariableDefinition("subscriber", VariableDirection.INPUT, "subscriber"), - ) - } - - @Test - fun `extract detects event subprocess type and extracts escalations`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - - val eventSubProcess = bpmnModel.flowNodes.first { it.id == "eventSubProcess_errorHandling" } - assertThat(eventSubProcess.nodeType).isEqualTo(BpmnNodeType.Activity.SubProcess(SubProcessKind.EVENT)) - - // the event subprocess start event carries the isInterrupting flag, defaulting to true when unset - assertThat(bpmnModel.flowNodes.first { it.id == "event_mailRejected" }.interrupting).isTrue() - // a regular (non-event-subprocess) start event has no interrupting flag - assertThat(bpmnModel.flowNodes.first { it.id == "startEvent_editionCreated" }.interrupting).isNull() - - assertThat(bpmnModel.escalations).containsExactlyInAnyOrder( - EscalationDefinition("escalationEndEvent_nofitySupport", "escalation_notifySupport", "200"), - EscalationDefinition("escalationEndEvent_nofitySupportAfterRepeatedError", "escalation_notifySupport", "200"), - ) - } - - @Test - fun `extract marks default sequence flow correctly`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/operaton-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - - val flowsById = bpmnModel.sequenceFlows.associateBy { it.id } - assertThat(flowsById["Flow_1jogut0"]).isEqualTo( - SequenceFlowDefinition("Flow_1jogut0", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", flowName = "Yes", isDefault = true) - ) - assertThat(flowsById["Flow_1gsz7wd"]).isEqualTo( - SequenceFlowDefinition("Flow_1gsz7wd", "gateway_hasSubscribers", "endEvent_noSubscribers", flowName = "No", conditionExpression = "\${subscribers.size() > 0}") - ) - } - - @Test - fun `extract marks a process with isExecutable false as non-executable`() { - val file = File(requireNotNull(javaClass.getResource("/bpmn/operaton-non-executable.bpmn")).toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.isExecutable).isFalse() - } - - @Test - fun `extract marks a process with isExecutable true as executable`() { - val file = File(requireNotNull(javaClass.getResource("/bpmn/operaton-subscribe-newsletter.bpmn")).toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.isExecutable).isTrue() - } - -} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeModelExtractorTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeModelExtractorTest.kt deleted file mode 100644 index 1189ec49..00000000 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/extractor/ZeebeModelExtractorTest.kt +++ /dev/null @@ -1,322 +0,0 @@ -package io.miragon.bpmn.adapter.outbound.engine.extractor - -import io.miragon.bpmn.domain.shared.SubProcessKind -import io.miragon.bpmn.domain.shared.BpmnNodeType -import io.miragon.bpmn.domain.shared.EventShape -import io.miragon.bpmn.domain.shared.EventDefinitionType -import io.miragon.bpmn.domain.shared.GatewayKind -import io.miragon.bpmn.domain.shared.TaskKind -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.CallActivityMapping -import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.CompensationType -import io.miragon.bpmn.domain.shared.EscalationDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition -import io.miragon.bpmn.domain.shared.EventDirection -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_KIND_KEY -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_VALUE_KEY -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition -import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.domain.testSubscribeNewsletterBpmnModel -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.io.File - -class ZeebeModelExtractorTest { - - private val underTest = ZeebeModelExtractor() - - @Test - fun `extract returns valid BpmnModel`() { - - // given: prepare bpmn file to be extracted - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-subscribe-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - - // when: extracting file to bpmn-model - val bpmnModel = underTest.extract(file.readBytes()) - - // then: assert that the model has expected content - assertThat(bpmnModel).isNotNull() - val zeebeServiceTasks = listOf( - ServiceTaskDefinition("Activity_SendConfirmationMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendConfirmationMail", IMPL_KIND_KEY to "JOB_WORKER")), - ServiceTaskDefinition("Activity_SendWelcomeMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendWelcomeMail", IMPL_KIND_KEY to "JOB_WORKER")), - ServiceTaskDefinition("EndEvent_RegistrationCompleted", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.registrationCompleted", IMPL_KIND_KEY to "JOB_WORKER")), - ServiceTaskDefinition("serviceTask_incrementSubscriptionCounter", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.incrementCounter", IMPL_KIND_KEY to "JOB_WORKER")), - ) - assertThat(bpmnModel).usingRecursiveComparison().ignoringCollectionOrder().isEqualTo( - testSubscribeNewsletterBpmnModel( - variantName = "withApproval", - detectedEngine = ProcessEngine.ZEEBE, - flowNodes = listOf( - FlowNodeDefinition("CallActivity_AbortRegistration", BpmnNodeType.Activity.CallActivity, - displayName = "Abort registration", - properties = FlowNodeProperties.CallActivity(CallActivityDefinition("CallActivity_AbortRegistration", "abort-registration", - mappings = listOf( - CallActivityMapping(VariableDirection.INPUT, source = "=subscriptionId", target = "subscriptionId"), - ), - engineSpecificProperties = mapOf( - CallActivityDefinition.PROPAGATE_ALL_INPUT_KEY to false, - CallActivityDefinition.PROPAGATE_ALL_OUTPUT_KEY to false, - ))), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "=subscriptionId")), - previousElements = listOf("Timer_After3Days"), - followingElements = listOf("CompensationEndEvent_RegistrationAborted")), - FlowNodeDefinition("Activity_ConfirmRegistration", BpmnNodeType.Activity.Task(TaskKind.RECEIVE), - displayName = "Confirm subscription", - properties = FlowNodeProperties.MessageEvent("Message_SubscriptionConfirmed", EventDirection.CATCH), - attachedElements = listOf("Timer_EveryDay"), - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_SendConfirmationMail"), - followingElements = listOf("EndEvent_SubscriptionConfirmed")), - FlowNodeDefinition("Activity_SendConfirmationMail", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Send confirmation mail", - properties = FlowNodeProperties.ServiceTask(zeebeServiceTasks[0]), - variables = listOf(VariableDefinition("testVariable", VariableDirection.INPUT, "=\"123\""), VariableDefinition("subscriptionId", VariableDirection.INPUT, "=subscriptionId")), - parentId = "SubProcess_Confirmation", - previousElements = listOf("StartEvent_RequestReceived", "Timer_EveryDay"), - followingElements = listOf("Activity_ConfirmRegistration")), - FlowNodeDefinition("Activity_SendWelcomeMail", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Send Welcome-Mail", - properties = FlowNodeProperties.ServiceTask(zeebeServiceTasks[1]), - variables = listOf( - VariableDefinition("subscriptionId", VariableDirection.INPUT, "=subscriptionId"), - VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "=subscriptionId"), - ), - previousElements = listOf("Gateway_SplitNotifications"), - followingElements = listOf("Gateway_JoinNotifications")), - FlowNodeDefinition("Activity_NotifyCommunity", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Notify community", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_NotifyCommunity", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.notifyCommunity", IMPL_KIND_KEY to "JOB_WORKER"))), - previousElements = listOf("Gateway_SplitNotifications"), - followingElements = listOf("Gateway_JoinNotifications")), - FlowNodeDefinition("Gateway_SplitNotifications", BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("SubProcess_Confirmation"), - followingElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity")), - FlowNodeDefinition("Gateway_JoinNotifications", BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity"), - followingElements = listOf("EndEvent_RegistrationCompleted")), - FlowNodeDefinition("CompensationEndEvent_RegistrationAborted", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.COMPENSATION), - displayName = "Registration aborted", - previousElements = listOf("CallActivity_AbortRegistration")), - FlowNodeDefinition("CompensationEvent_OnSubscriptionCounter", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.COMPENSATION), - displayName = "Registration aborted", - attachedToRef = "serviceTask_incrementSubscriptionCounter", interrupting = true), - FlowNodeDefinition("CompensationTask_DecrementSubscriptionCounter", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Decrement subscription counter", - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT, "=subscriptionId"))), - FlowNodeDefinition("EndEvent_RegistrationCompleted", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.MESSAGE), - displayName = "Registration completed", - properties = FlowNodeProperties.ServiceTask(zeebeServiceTasks[2]), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "=subscriptionId")), - previousElements = listOf("Gateway_JoinNotifications")), - FlowNodeDefinition("EndEvent_RegistrationNotPossible", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.SIGNAL), - displayName = "Registration not possible", - properties = FlowNodeProperties.SignalEvent("Signal_RegistrationNotPossible", EventDirection.THROW), - previousElements = listOf("ErrorEvent_InvalidMail")), - FlowNodeDefinition("EndEvent_SubscriptionConfirmed", BpmnNodeType.Event(EventShape.END_EVENT), - displayName = "Subscription confirmed", - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_ConfirmRegistration")), - FlowNodeDefinition("ErrorEvent_InvalidMail", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.ERROR), - displayName = "Invalid Mail", - attachedToRef = "SubProcess_Confirmation", interrupting = true, - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "=subscriptionId")), - followingElements = listOf("EndEvent_RegistrationNotPossible")), - FlowNodeDefinition("serviceTask_incrementSubscriptionCounter", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Increment subscription counter", - properties = FlowNodeProperties.ServiceTask(zeebeServiceTasks[3]), - attachedElements = listOf("CompensationEvent_OnSubscriptionCounter"), - previousElements = listOf("StartEvent_SubmitRegistrationForm"), - followingElements = listOf("SubProcess_Confirmation")), - FlowNodeDefinition("StartEvent_RequestReceived", BpmnNodeType.Event(EventShape.START_EVENT), - displayName = "Subscription requested", - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "=subscriptionId")), - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail")), - FlowNodeDefinition("StartEvent_SubmitRegistrationForm", BpmnNodeType.Event(EventShape.START_EVENT, EventDefinitionType.MESSAGE), - displayName = "Submit newsletter form", - properties = FlowNodeProperties.MessageEvent("Message_FormSubmitted", EventDirection.CATCH), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT, "=subscriptionId")), - followingElements = listOf("serviceTask_incrementSubscriptionCounter")), - FlowNodeDefinition("SubProcess_Confirmation", BpmnNodeType.Activity.SubProcess(SubProcessKind.PLAIN), - displayName = "Subscription Confirmation", - attachedElements = listOf("ErrorEvent_InvalidMail", "Timer_After3Days"), - previousElements = listOf("serviceTask_incrementSubscriptionCounter"), - followingElements = listOf("Gateway_SplitNotifications")), - FlowNodeDefinition("Timer_After3Days", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - displayName = "After 3 days", - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_After3Days", "Duration", "=testVariable")), - attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("CallActivity_AbortRegistration")), - FlowNodeDefinition("Timer_EveryDay", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - displayName = "Every day", - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_EveryDay", "Duration", "PT1M")), - attachedToRef = "Activity_ConfirmRegistration", interrupting = false, - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail")), - ), - sequenceFlows = listOf( - SequenceFlowDefinition("Flow_05i3x1y", "StartEvent_RequestReceived", "Activity_SendConfirmationMail"), - SequenceFlowDefinition("Flow_09cuvzp", "SubProcess_Confirmation", "Gateway_SplitNotifications"), - SequenceFlowDefinition("Flow_0i2ctuv", "ErrorEvent_InvalidMail", "EndEvent_RegistrationNotPossible"), - SequenceFlowDefinition("Flow_0x4ewvb", "Timer_EveryDay", "Activity_SendConfirmationMail"), - SequenceFlowDefinition("Flow_0zdmt0t", "serviceTask_incrementSubscriptionCounter", "SubProcess_Confirmation"), - SequenceFlowDefinition("Flow_16hub0n", "Gateway_SplitNotifications", "Activity_SendWelcomeMail"), - SequenceFlowDefinition("Flow_1862jd8", "Gateway_JoinNotifications", "EndEvent_RegistrationCompleted"), - SequenceFlowDefinition("Flow_1bckm43", "Activity_SendConfirmationMail", "Activity_ConfirmRegistration"), - SequenceFlowDefinition("Flow_1bsb8no", "CallActivity_AbortRegistration", "CompensationEndEvent_RegistrationAborted"), - SequenceFlowDefinition("Flow_1cpwe57", "Activity_ConfirmRegistration", "EndEvent_SubscriptionConfirmed"), - SequenceFlowDefinition("Flow_1csfyyz", "StartEvent_SubmitRegistrationForm", "serviceTask_incrementSubscriptionCounter"), - SequenceFlowDefinition("Flow_1duwy83", "Activity_NotifyCommunity", "Gateway_JoinNotifications"), - SequenceFlowDefinition("Flow_1i7hjid", "Activity_SendWelcomeMail", "Gateway_JoinNotifications"), - SequenceFlowDefinition("Flow_1l1lj4m", "Timer_After3Days", "CallActivity_AbortRegistration"), - SequenceFlowDefinition("Flow_1p5t47z", "Gateway_SplitNotifications", "Activity_NotifyCommunity"), - ), - messages = listOf( - MessageDefinition("StartEvent_SubmitRegistrationForm", "Message_FormSubmitted"), - MessageDefinition("Activity_ConfirmRegistration", "Message_SubscriptionConfirmed", engineSpecificProperties = mapOf("correlationKey" to "=subscriptionId")), - ), - compensations = listOf( - CompensationDefinition("CompensationEndEvent_RegistrationAborted", CompensationType.THROWING, engineSpecificProperties = mapOf("activityRef" to "serviceTask_incrementSubscriptionCounter", "waitForCompletion" to false)), - CompensationDefinition("CompensationEvent_OnSubscriptionCounter", CompensationType.CATCHING, engineSpecificProperties = mapOf("waitForCompletion" to false)), - ), - ) - ) - } - - @Test - fun `extract returns variantName from process-level extension properties`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-subscribe-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variantName).isEqualTo("withApproval") - } - - @Test - fun `extract returns null variantName when not specified`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variantName).isNull() - } - - @Test - fun `extract captures call-activity io-mapping targets and propagate-all flags`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-subscribe-newsletter.bpmn")) - val bpmnModel = underTest.extract(File(resourceUrl.toURI()).readBytes()) - val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } - assertThat(callActivity.inputMappings).containsExactly( - CallActivityMapping(VariableDirection.INPUT, source = "=subscriptionId", target = "subscriptionId"), - ) - assertThat(callActivity.outputMappings).isEmpty() - assertThat(callActivity.propagateAllInputVariables).isFalse() - assertThat(callActivity.propagateAllOutputVariables).isFalse() - } - - @Test - fun `extract returns multi-instance variables`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.variables).containsExactlyInAnyOrder( - VariableDefinition("test", VariableDirection.INPUT, "null"), - VariableDefinition("authors", VariableDirection.INPUT, "=authors"), - VariableDefinition("author", VariableDirection.INPUT, "author"), - VariableDefinition("author", VariableDirection.OUTPUT, "=author"), - VariableDefinition("subscribers", VariableDirection.INPUT, "=subscribers"), - VariableDefinition("subscribers", VariableDirection.OUTPUT, "=subscribers"), - VariableDefinition("subscriber", VariableDirection.INPUT, "subscriber"), - VariableDefinition("results", VariableDirection.OUTPUT, "results"), - VariableDefinition("result", VariableDirection.OUTPUT, "=result"), - VariableDefinition("method", VariableDirection.INPUT, "POST"), - VariableDefinition("url", VariableDirection.INPUT, "https://api.example.com/newsletter"), - VariableDefinition("apiResponse", VariableDirection.OUTPUT, "=response"), - ) - } - - @Test - fun `extract classifies element-template service tasks as connectors`() { - val file = File(requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")).toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - - val kindByType = bpmnModel.serviceTasks.associate { - it.engineSpecificProperties[IMPL_VALUE_KEY] to it.engineSpecificProperties[IMPL_KIND_KEY] - } - assertThat(kindByType["io.camunda:http-json:1"]).isEqualTo("CONNECTOR") - assertThat(kindByType["newsletter.loadSubscribers"]).isEqualTo("JOB_WORKER") - assertThat(kindByType["newsletter.notifyAuthors"]).isEqualTo("JOB_WORKER") - } - - @Test - fun `extract detects event subprocess type and extracts escalations`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - - val eventSubProcess = bpmnModel.flowNodes.first { it.id == "eventSubProcess_errorHandling" } - assertThat(eventSubProcess.nodeType).isEqualTo(BpmnNodeType.Activity.SubProcess(SubProcessKind.EVENT)) - - // the event subprocess start event carries the isInterrupting flag, defaulting to true when unset - assertThat(bpmnModel.flowNodes.first { it.id == "event_mailRejected" }.interrupting).isTrue() - // a regular (non-event-subprocess) start event has no interrupting flag - assertThat(bpmnModel.flowNodes.first { it.id == "startEvent_editionCreated" }.interrupting).isNull() - - assertThat(bpmnModel.escalations).containsExactlyInAnyOrder( - EscalationDefinition("escalationEndEvent_nofitySupport", "escalation_notifySupport", "200"), - EscalationDefinition("escalationEndEvent_nofitySupportAfterRepeatedError", "escalation_notifySupport", "200"), - ) - } - - @Test - fun `extract marks default sequence flow correctly`() { - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c8-send-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - - val flowsById = bpmnModel.sequenceFlows.associateBy { it.id } - assertThat(flowsById["Flow_1jogut0"]).isEqualTo( - SequenceFlowDefinition("Flow_1jogut0", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", flowName = "Yes", isDefault = true) - ) - assertThat(flowsById["Flow_1gsz7wd"]).isEqualTo( - SequenceFlowDefinition("Flow_1gsz7wd", "gateway_hasSubscribers", "endEvent_noSubscribers", flowName = "No", conditionExpression = "=subscribers.size() > 0") - ) - } - - @Test - fun `extract stays tolerant and leaves a call activity without calledElement for later validation`() { - - // given: a Camunda 7 model with a call activity and no zeebe:calledElement - val resourceUrl = requireNotNull(javaClass.getResource("/bpmn/c7-subscribe-newsletter.bpmn")) - val file = File(resourceUrl.toURI()) - - // when: extracting the mismatched model - val bpmnModel = underTest.extract(file.readBytes()) - - // then: extraction does not validate or fail here - val callActivity = bpmnModel.callActivities.single { it.id == "CallActivity_AbortRegistration" } - assertThat(callActivity.hasCalledElement()).isFalse() - assertThat(bpmnModel.detectedEngine).isEqualTo(ProcessEngine.CAMUNDA_7) - } - - @Test - fun `extract marks a process with isExecutable false as non-executable`() { - val file = File(requireNotNull(javaClass.getResource("/bpmn/c8-non-executable.bpmn")).toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.isExecutable).isFalse() - } - - @Test - fun `extract marks a process with isExecutable true as executable`() { - val file = File(requireNotNull(javaClass.getResource("/bpmn/c8-subscribe-newsletter.bpmn")).toURI()) - val bpmnModel = underTest.extract(file.readBytes()) - assertThat(bpmnModel.isExecutable).isTrue() - } - -} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/SecureBpmnParserTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/SecureBpmnParserTest.kt similarity index 51% rename from bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/SecureBpmnParserTest.kt rename to bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/SecureBpmnParserTest.kt index 17a0ca3d..f23a86c3 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/SecureBpmnParserTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/engine/xml/SecureBpmnParserTest.kt @@ -1,4 +1,4 @@ -package io.miragon.bpmn.adapter.outbound.engine +package io.miragon.bpmn.adapter.outbound.engine.xml import org.assertj.core.api.Assertions.assertThatCode import org.assertj.core.api.Assertions.assertThatThrownBy @@ -19,6 +19,27 @@ class SecureBpmnParserTest { .hasMessageContaining("DOCTYPE") } + @Test + fun `reports malformed XML as malformed, not as a DOCTYPE violation`() { + + // given: a file that is not well-formed XML at all + val truncated = "): List { + return pairs.map { (source, target) -> + SequenceFlowDefinition(id = "$source->$target", sourceRef = source, targetRef = target) + } + } + + private fun outgoingOf(id: String, flows: List): List { + return flows.filter { it.sourceRef == id }.map { it.id!! } + } + + private fun incomingOf(id: String, flows: List): List { + return flows.filter { it.targetRef == id }.map { it.id!! } + } + + private fun task(id: String, flows: List): FlowNodeDefinition { + return FlowNodeDefinition.Activity.Task( + id = id, + kind = TaskKind.SERVICE, + incoming = incomingOf(id, flows), + outgoing = outgoingOf(id, flows), + ) + } + + private fun event( id: String, - type: BpmnNodeType = BpmnNodeType.Activity.Task(TaskKind.SERVICE), - previousElements: List = emptyList(), - followingElements: List = emptyList(), - parentId: String? = null, + shape: EventShape, + flows: List, attachedToRef: String? = null, - ) = FlowNodeDefinition( - id = id, - nodeType = type, - previousElements = previousElements, - followingElements = followingElements, - parentId = parentId, - attachedToRef = attachedToRef, - ) + ): FlowNodeDefinition { + return FlowNodeDefinition.Event( + id = id, + shape = shape, + incoming = incomingOf(id, flows), + outgoing = outgoingOf(id, flows), + attachedToRef = attachedToRef, + ) + } + + private fun gateway(id: String, kind: GatewayKind, flows: List): FlowNodeDefinition { + return FlowNodeDefinition.Gateway( + id = id, + kind = kind, + incoming = incomingOf(id, flows), + outgoing = outgoingOf(id, flows), + ) + } @Test fun `linear chain is sorted start to end`() { // given: a linear start → task → end chain - val start = node(id = "Start", type = BpmnNodeType.Event(EventShape.START_EVENT), followingElements = listOf("Task")) - val task = node(id = "Task", previousElements = listOf("Start"), followingElements = listOf("End")) - val end = node(id = "End", type = BpmnNodeType.Event(EventShape.END_EVENT), previousElements = listOf("Task")) + val flows = edges("Start" to "Task", "Task" to "End") + val start = event("Start", EventShape.START_EVENT, flows) + val task = task("Task", flows) + val end = event("End", EventShape.END_EVENT, flows) // when: sorting the unsorted list - val result = FlowNodeSorter.sort(listOf(task, end, start)) + val result = FlowNodeSorter.sort(listOf(task, end, start), flows) // then: nodes appear in process order assertThat(result.map { it.id }).containsExactly("Start", "Task", "End") @@ -46,13 +81,14 @@ class FlowNodeSorterTest { fun `start events are visited before other top-level nodes`() { // given: two start events feeding the same task - val startA = node(id = "Start_A", type = BpmnNodeType.Event(EventShape.START_EVENT), followingElements = listOf("Task")) - val startB = node(id = "Start_B", type = BpmnNodeType.Event(EventShape.START_EVENT), followingElements = listOf("Task")) - val task = node(id = "Task", previousElements = listOf("Start_A", "Start_B"), followingElements = listOf("End")) - val end = node(id = "End", type = BpmnNodeType.Event(EventShape.END_EVENT), previousElements = listOf("Task")) + val flows = edges("Start_A" to "Task", "Start_B" to "Task", "Task" to "End") + val startA = event("Start_A", EventShape.START_EVENT, flows) + val startB = event("Start_B", EventShape.START_EVENT, flows) + val task = task("Task", flows) + val end = event("End", EventShape.END_EVENT, flows) // when: sorting - val result = FlowNodeSorter.sort(listOf(task, end, startB, startA)) + val result = FlowNodeSorter.sort(listOf(task, end, startB, startA), flows) val ids = result.map { it.id } // then: Start_A (alphabetically first) leads, all nodes appear exactly once @@ -64,14 +100,15 @@ class FlowNodeSorterTest { fun `boundary event appears after its parent`() { // given: a task with an attached boundary event - val start = node(id = "Start", type = BpmnNodeType.Event(EventShape.START_EVENT), followingElements = listOf("Task")) - val task = node(id = "Task", previousElements = listOf("Start"), followingElements = listOf("End")) - val boundary = node(id = "Boundary", type = BpmnNodeType.Event(EventShape.BOUNDARY_EVENT), attachedToRef = "Task", followingElements = listOf("ErrorEnd")) - val end = node(id = "End", type = BpmnNodeType.Event(EventShape.END_EVENT), previousElements = listOf("Task")) - val errorEnd = node(id = "ErrorEnd", type = BpmnNodeType.Event(EventShape.END_EVENT), previousElements = listOf("Boundary")) + val flows = edges("Start" to "Task", "Task" to "End", "Boundary" to "ErrorEnd") + val start = event("Start", EventShape.START_EVENT, flows) + val task = task("Task", flows) + val boundary = event("Boundary", EventShape.BOUNDARY_EVENT, flows, attachedToRef = "Task") + val end = event("End", EventShape.END_EVENT, flows) + val errorEnd = event("ErrorEnd", EventShape.END_EVENT, flows) // when: sorting - val result = FlowNodeSorter.sort(listOf(end, errorEnd, boundary, task, start)) + val result = FlowNodeSorter.sort(listOf(end, errorEnd, boundary, task, start), flows) val ids = result.map { it.id } // then: Boundary comes after Task, ErrorEnd comes after Boundary @@ -80,34 +117,51 @@ class FlowNodeSorterTest { } @Test - fun `subprocess children are inlined after subprocess`() { - - // given: a subprocess with child nodes - val start = node(id = "Start", type = BpmnNodeType.Event(EventShape.START_EVENT), followingElements = listOf("Sub")) - val sub = node(id = "Sub", type = BpmnNodeType.Activity.SubProcess(SubProcessKind.PLAIN), previousElements = listOf("Start"), followingElements = listOf("End")) - val subStart = node(id = "SubStart", type = BpmnNodeType.Event(EventShape.START_EVENT), parentId = "Sub", followingElements = listOf("SubTask")) - val subTask = node(id = "SubTask", parentId = "Sub", previousElements = listOf("SubStart"), followingElements = listOf("SubEnd")) - val subEnd = node(id = "SubEnd", type = BpmnNodeType.Event(EventShape.END_EVENT), parentId = "Sub", previousElements = listOf("SubTask")) - val end = node(id = "End", type = BpmnNodeType.Event(EventShape.END_EVENT), previousElements = listOf("Sub")) - - // when: sorting - val result = FlowNodeSorter.sort(listOf(end, subEnd, subTask, subStart, sub, start)) - - // then: subprocess children are inlined immediately after the subprocess - assertThat(result.map { it.id }).containsExactly("Start", "Sub", "SubStart", "SubTask", "SubEnd", "End") + fun `subprocess is ordered in its scope and its children are sorted separately`() { + + // given: a top-level scope containing a sub-process. Sub-process children are no longer inlined into + // the parent scope — they live inside the sub-process node and are sorted by re-applying the sorter. + val topFlows = edges("Start" to "Sub", "Sub" to "End") + val start = event("Start", EventShape.START_EVENT, topFlows) + val childFlows = edges("SubStart" to "SubTask", "SubTask" to "SubEnd") + val subStart = event("SubStart", EventShape.START_EVENT, childFlows) + val subTask = task("SubTask", childFlows) + val subEnd = event("SubEnd", EventShape.END_EVENT, childFlows) + val sub = FlowNodeDefinition.Activity.SubProcess( + id = "Sub", + kind = SubProcessKind.PLAIN, + incoming = incomingOf("Sub", topFlows), + outgoing = outgoingOf("Sub", topFlows), + flowNodes = listOf(subEnd, subTask, subStart), + sequenceFlows = childFlows, + ) + val end = event("End", EventShape.END_EVENT, topFlows) + + // when: sorting the top scope + val topResult = FlowNodeSorter.sort(listOf(end, sub, start), topFlows) + + // then: the sub-process is ordered between start and end, without its children leaking into the scope + assertThat(topResult.map { it.id }).containsExactly("Start", "Sub", "End") + + // and when: sorting the sub-process's own scope + val childResult = FlowNodeSorter.sort(sub.flowNodes, sub.sequenceFlows) + + // then: its children appear in process order + assertThat(childResult.map { it.id }).containsExactly("SubStart", "SubTask", "SubEnd") } @Test fun `cycles do not cause infinite loops`() { // given: a cyclic A ↔ B loop - val start = node(id = "Start", type = BpmnNodeType.Event(EventShape.START_EVENT), followingElements = listOf("A")) - val a = node(id = "A", previousElements = listOf("Start", "B"), followingElements = listOf("B")) - val b = node(id = "B", previousElements = listOf("A"), followingElements = listOf("A", "End")) - val end = node(id = "End", type = BpmnNodeType.Event(EventShape.END_EVENT), previousElements = listOf("B")) + val flows = edges("Start" to "A", "A" to "B", "B" to "A", "B" to "End") + val start = event("Start", EventShape.START_EVENT, flows) + val a = task("A", flows) + val b = task("B", flows) + val end = event("End", EventShape.END_EVENT, flows) // when: sorting - val result = FlowNodeSorter.sort(listOf(b, a, end, start)) + val result = FlowNodeSorter.sort(listOf(b, a, end, start), flows) // then: no exception; each node appears exactly once assertThat(result.map { it.id }).containsExactlyInAnyOrder("Start", "A", "B", "End") @@ -118,12 +172,13 @@ class FlowNodeSorterTest { fun `already sorted input is idempotent`() { // given: nodes already in correct order - val start = node(id = "Start", type = BpmnNodeType.Event(EventShape.START_EVENT), followingElements = listOf("Task")) - val task = node(id = "Task", previousElements = listOf("Start"), followingElements = listOf("End")) - val end = node(id = "End", type = BpmnNodeType.Event(EventShape.END_EVENT), previousElements = listOf("Task")) + val flows = edges("Start" to "Task", "Task" to "End") + val start = event("Start", EventShape.START_EVENT, flows) + val task = task("Task", flows) + val end = event("End", EventShape.END_EVENT, flows) // when: sorting - val result = FlowNodeSorter.sort(listOf(start, task, end)) + val result = FlowNodeSorter.sort(listOf(start, task, end), flows) // then: order is unchanged assertThat(result.map { it.id }).containsExactly("Start", "Task", "End") @@ -133,14 +188,21 @@ class FlowNodeSorterTest { fun `exclusive gateway branches appear after gateway`() { // given: a gateway splitting into two branches - val start = node(id = "Start", type = BpmnNodeType.Event(EventShape.START_EVENT), followingElements = listOf("GW")) - val gw = node(id = "GW", type = BpmnNodeType.Gateway(GatewayKind.EXCLUSIVE), previousElements = listOf("Start"), followingElements = listOf("Branch_A", "Branch_B")) - val branchA = node(id = "Branch_A", previousElements = listOf("GW"), followingElements = listOf("End")) - val branchB = node(id = "Branch_B", previousElements = listOf("GW"), followingElements = listOf("End")) - val end = node(id = "End", type = BpmnNodeType.Event(EventShape.END_EVENT), previousElements = listOf("Branch_A", "Branch_B")) + val flows = edges( + "Start" to "GW", + "GW" to "Branch_A", + "GW" to "Branch_B", + "Branch_A" to "End", + "Branch_B" to "End", + ) + val start = event("Start", EventShape.START_EVENT, flows) + val gw = gateway("GW", GatewayKind.EXCLUSIVE, flows) + val branchA = task("Branch_A", flows) + val branchB = task("Branch_B", flows) + val end = event("End", EventShape.END_EVENT, flows) // when: sorting - val result = FlowNodeSorter.sort(listOf(end, branchB, gw, branchA, start)) + val result = FlowNodeSorter.sort(listOf(end, branchB, gw, branchA, start), flows) val ids = result.map { it.id } // then: both branches appear after their gateway diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonActivityFacetsTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonActivityFacetsTest.kt new file mode 100644 index 00000000..4b5e385e --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonActivityFacetsTest.kt @@ -0,0 +1,119 @@ +package io.miragon.bpmn.adapter.outbound.json + +import io.miragon.bpmn.adapter.inbound.CreateProcessJsonInMemoryPlugin +import io.miragon.bpmn.domain.shared.ProcessEngine +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * Guards that the activity facets of [#73](https://github.com/Miragon/bpmn-to-code/issues/73) and + * [#74](https://github.com/Miragon/bpmn-to-code/issues/74) survive all the way into the published JSON. + * + * The golden fixtures use the subscription process, which has neither facet, so the mapper path for both is + * only covered here. The parity assertion is the point of the redesign: the normalised layer stays the same + * across engines, and only the expressions differ. + */ +class ProcessJsonActivityFacetsTest { + + private val underTest = CreateProcessJsonInMemoryPlugin() + + @Test + fun `multi-instance loop characteristics reach the json for every engine`() { + + // when + val documents = sendNewsletterPerEngine() + + // then: sequential and the element binding are engine-independent + documents.forEach { (engine, document) -> + val loop = document.flowNode("serviceTask_sendToSubscriber")["multiInstance"]?.jsonObject + assertThat(loop).describedAs("$engine multiInstance").isNotNull + assertThat(loop?.text("sequential")).describedAs("$engine sequential").isEqualTo("true") + assertThat(loop?.text("inputElement")).describedAs("$engine inputElement").isEqualTo("subscriber") + } + + // and: a non-sequential loop is reported as such rather than omitted + documents.forEach { (engine, document) -> + val loop = document.flowNode("serviceTask_notifyAuthor")["multiInstance"]?.jsonObject + assertThat(loop?.text("sequential")).describedAs("$engine sequential").isEqualTo("false") + } + } + + @Test + fun `io mappings reach the json for every engine`() { + + // when + val documents = sendNewsletterPerEngine() + + // then: the parameter targets are normalised, the sources stay in the engine's own syntax + documents.forEach { (engine, document) -> + val ioMapping = document.flowNode("serviceTask_loadSubscribers")["ioMapping"]?.jsonObject + assertThat(ioMapping).describedAs("$engine ioMapping").isNotNull + val targets = ioMapping?.get("outputs")?.jsonArray?.map { it.jsonObject.text("target") } + assertThat(targets).describedAs("$engine output targets").containsExactly("subscribers", "author") + } + } + + @Test + fun `the zeebe output collection binding is preserved verbatim`() { + + // given: only Zeebe models an output collection, so it is asserted on its own + val document = sendNewsletterPerEngine().getValue(ProcessEngine.ZEEBE) + + // then + val loop = document.flowNode("serviceTask_notifyAuthor").getValue("multiInstance").jsonObject + assertThat(loop.text("inputCollection")).isEqualTo("=authors") + assertThat(loop.text("outputCollection")).isEqualTo("results") + assertThat(loop.text("outputElement")).isEqualTo("=result") + } + + @Test + fun `activities without either facet omit both fields`() { + + // when + val document = sendNewsletterPerEngine().getValue(ProcessEngine.ZEEBE) + + // then: absent facets are omitted rather than serialised as null or as an empty object + val node = document.flowNode("serviceTask_sendToSubscriber") + assertThat(node).doesNotContainKey("ioMapping") + assertThat(document.flowNode("serviceTask_loadSubscribers")).doesNotContainKey("multiInstance") + } + + /** + * The send-newsletter fixture — the only one carrying both facets — generated for every engine. + */ + private fun sendNewsletterPerEngine(): Map { + return mapOf( + ProcessEngine.ZEEBE to generate(ProcessEngine.ZEEBE, "c8-send-newsletter"), + ProcessEngine.CAMUNDA_7 to generate(ProcessEngine.CAMUNDA_7, "c7-send-newsletter"), + ProcessEngine.OPERATON to generate(ProcessEngine.OPERATON, "operaton-send-newsletter"), + ) + } + + private fun generate(engine: ProcessEngine, fixture: String): JsonObject { + val input = CreateProcessJsonInMemoryPlugin.BpmnInput( + bpmnXml = readResource("/bpmn/$fixture.bpmn"), + processName = fixture, + ) + val generated = underTest.execute(bpmnContents = listOf(input), engine = engine).single() + return Json.parseToJsonElement(generated.content).jsonObject + } + + private fun JsonObject.flowNode(id: String): JsonObject { + return getValue("process").jsonObject + .getValue("flowNodes").jsonArray + .map { it.jsonObject } + .single { it.text("id") == id } + } + + private fun JsonObject.text(field: String): String? = this[field]?.jsonPrimitive?.content + + private fun readResource(path: String): String { + return requireNotNull(javaClass.getResourceAsStream(path)) { "missing test resource $path" } + .use { it.readBytes().decodeToString() } + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonEndToEndTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonEndToEndTest.kt new file mode 100644 index 00000000..e052f42b --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonEndToEndTest.kt @@ -0,0 +1,60 @@ +package io.miragon.bpmn.adapter.outbound.json + +import io.miragon.bpmn.adapter.inbound.CreateProcessJsonInMemoryPlugin +import io.miragon.bpmn.domain.shared.ProcessEngine +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +/** + * Snapshots the **whole** pipeline: a real BPMN file in, the published JSON out. + * + * The other JSON snapshots ([BpmnJsonGeneratorTest]) build their model by hand, so they never see what an + * engine dialect actually produces — no `extensions`, no `ioMapping`, no `engineAttributes`, and variables + * without their expression. `ProcessJsonSchemaTest` does run real files through, but only checks shape and + * reference integrity. Neither would notice a change in emitted *content*, which is how the raw + * `zeebe:ioMapping` extensions came to restate `ioMapping` unnoticed. + * + * Regenerate with `-Dgolden.update=true` after reviewing the diff. + */ +class ProcessJsonEndToEndTest { + + private val underTest = CreateProcessJsonInMemoryPlugin() + + @ParameterizedTest + @CsvSource( + "ZEEBE, c8-subscribe-newsletter", + "CAMUNDA_7, c7-subscribe-newsletter", + "OPERATON, operaton-subscribe-newsletter", + ) + fun `real bpmn produces the committed json`(engine: ProcessEngine, fixture: String) { + + // given: the shared fixture for this engine + val input = CreateProcessJsonInMemoryPlugin.BpmnInput( + bpmnXml = readResource("/bpmn/$fixture.bpmn"), + processName = fixture, + ) + + // when: running the real extraction and JSON generation + val generated = underTest.execute(listOf(input), engine).single().content + + // then: it matches the committed snapshot byte for byte + assertThat(generated).isEqualToIgnoringWhitespace(golden(fixture, generated)) + } + + private fun golden(fixture: String, generated: String): String { + val path = "/json/e2e/$fixture.json" + if (System.getProperty("golden.update") == "true") { + File("src/test/resources$path").apply { parentFile.mkdirs() }.writeText(generated) + return generated + } + return readResource(path) + } + + private fun readResource(path: String): String { + return requireNotNull(javaClass.getResourceAsStream(path)) { "missing resource $path" } + .bufferedReader() + .readText() + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonSchemaTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonSchemaTest.kt new file mode 100644 index 00000000..6f6819eb --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/json/ProcessJsonSchemaTest.kt @@ -0,0 +1,190 @@ +package io.miragon.bpmn.adapter.outbound.json + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.networknt.schema.JsonSchemaFactory +import com.networknt.schema.SpecVersion +import io.miragon.bpmn.adapter.inbound.CreateProcessJsonInMemoryPlugin +import io.miragon.bpmn.domain.shared.ProcessEngine +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +/** + * Guards the published process-JSON contract (ADR 018). + * + * Every generated file points at `docs/public/schema/process-model/2.0.json` via `$schema`, so the schema + * and the emitted output have to stay in step. The schema is validated from the classpath rather than the + * published URL, so the test runs offline. + * + * JSON Schema only checks *shape*. The reference-integrity test below covers the part it cannot see — + * that `incoming` / `outgoing` really hold sequence-flow ids of the same scope, and that every `…Ref` + * resolves into `definitions`. Without it a regression back to node-id relations would pass unnoticed. + */ +class ProcessJsonSchemaTest { + + private val underTest = CreateProcessJsonInMemoryPlugin() + + private val mapper = ObjectMapper() + + private val schema = JsonSchemaFactory + .getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(requireNotNull(javaClass.getResourceAsStream("/process-model/2.0.json"))) + + @ParameterizedTest + @EnumSource(ProcessEngine::class) + fun `generated process json conforms to the published schema`(engine: ProcessEngine) { + + // when: every shared fixture of this engine runs through the real pipeline + val generated = generateAll(engine) + + // then: no fixture produces output the published schema rejects + assertThat(generated).isNotEmpty + generated.forEach { (fixture, json) -> + val violations = schema.validate(mapper.readTree(json)).map { "${it.instanceLocation}: ${it.message}" } + assertThat(violations).describedAs(fixture).isEmpty() + } + } + + @Test + fun `golden json fixtures conform to the published schema`() { + + // given: the committed fixtures, which also cover the merged multi-variant shape + val goldenFiles = listOf( + "/json/NewsletterSubscriptionProcess.json", + "/json/MultiVariantNewsletterProcess.json", + "/json/e2e/c8-subscribe-newsletter.json", + "/json/e2e/c7-subscribe-newsletter.json", + "/json/e2e/operaton-subscribe-newsletter.json", + ) + + // then + goldenFiles.forEach { path -> + val violations = schema.validate(mapper.readTree(readResource(path))).map { it.message } + assertThat(violations).describedAs(path).isEmpty() + } + } + + @ParameterizedTest + @EnumSource(ProcessEngine::class) + fun `every reference in the generated json resolves`(engine: ProcessEngine) { + + // when + val generated = generateAll(engine) + + // then: relations point at sequence flows of their own scope, and every ...Ref resolves + generated.forEach { (fixture, json) -> + val document = mapper.readTree(json) + val declaredIds = document.definitionIds() + document.scopes().forEach { scope -> + val flowIds = scope["sequenceFlows"].idsOf() + scope["flowNodes"].forEach { node -> + assertThat(node.stringsAt("incoming") + node.stringsAt("outgoing")) + .describedAs("$fixture / ${node["id"].asText()} relations") + .isSubsetOf(flowIds) + assertThat(node.referencedDefinitionIds()) + .describedAs("$fixture / ${node["id"].asText()} references") + .isSubsetOf(declaredIds) + } + } + } + } + + @Test + fun `a message correlation key is declared once, on the message it belongs to`() { + + // given: a Zeebe process whose zeebe:subscription sits on the bpmn:Message root element + val input = CreateProcessJsonInMemoryPlugin.BpmnInput( + bpmnXml = readResource("/bpmn/c8-subscribe-newsletter.bpmn"), + processName = "c8-subscribe-newsletter", + ) + val generated = underTest.execute(listOf(input), ProcessEngine.ZEEBE).single() + + // then: it is a property of the entity, not repeated on every referencing event + val document = mapper.readTree(generated.content) + val message = document.at("/definitions/messages").single { it["name"].asText() == "Message_SubscriptionConfirmed" } + assertThat(message["correlationKey"].asText()).isEqualTo("=subscriptionId") + assertThat(generated.content).doesNotContain("\"subscription\"") + } + + /** + * Each fixture of [engine] run through the real pipeline, paired with its fixture name. + */ + private fun generateAll(engine: ProcessEngine): List> { + return fixturesFor(engine).flatMap { fixture -> + val input = CreateProcessJsonInMemoryPlugin.BpmnInput( + bpmnXml = readResource("/bpmn/$fixture.bpmn"), + processName = fixture, + ) + underTest.execute(bpmnContents = listOf(input), engine = engine).map { fixture to it.content } + } + } + + /** + * The process scope plus every variant and every nested sub-process scope. + */ + private fun JsonNode.scopes(): List { + val roots = listOf(this["process"]) + (this["variants"]?.toList() ?: emptyList()) + return roots.flatMap { it.withNestedScopes() } + } + + private fun JsonNode.withNestedScopes(): List { + val children = this["flowNodes"]?.filter { it.has("flowNodes") } ?: emptyList() + return listOf(this) + children.flatMap { it.withNestedScopes() } + } + + private fun JsonNode.definitionIds(): List { + val definitions = this["definitions"] ?: return emptyList() + return definitions.flatMap { group -> group.idsOf() } + } + + private fun JsonNode.referencedDefinitionIds(): List { + val fromEvents = this["eventDefinitions"]?.flatMap { definition -> + referenceFields.mapNotNull { field -> definition[field]?.asText() } + } ?: emptyList() + return fromEvents + listOfNotNull(this["messageRef"]?.asText()) + } + + private fun JsonNode?.idsOf(): List = this?.map { it["id"].asText() } ?: emptyList() + + private fun JsonNode.stringsAt(field: String): List = this[field]?.map { it.asText() } ?: emptyList() + + private fun readResource(path: String): String = + requireNotNull(javaClass.getResourceAsStream(path)) { "missing test resource $path" } + .use { it.readBytes().decodeToString() } + + private fun fixturesFor(engine: ProcessEngine): List = when (engine) { + ProcessEngine.ZEEBE -> zeebeFixtures + ProcessEngine.CAMUNDA_7 -> camunda7Fixtures + ProcessEngine.OPERATON -> operatonFixtures + } + + private val referenceFields = listOf( + "messageRef", + "signalRef", + "errorRef", + "escalationRef", + ) + + private val zeebeFixtures = listOf( + "c8-subscribe-newsletter", + "c8-send-newsletter", + "c8-non-executable", + ) + + private val camunda7Fixtures = listOf( + "c7-subscribe-newsletter", + "c7-send-newsletter", + "c7-additional-variables", + "c7-non-executable", + "c7-no-executable-attr", + ) + + private val operatonFixtures = listOf( + "operaton-subscribe-newsletter", + "operaton-send-newsletter", + "operaton-additional-variables", + "operaton-non-executable", + ) +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/shared/BpmnTypeNameTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/shared/BpmnTypeNameTest.kt new file mode 100644 index 00000000..159a2008 --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/shared/BpmnTypeNameTest.kt @@ -0,0 +1,84 @@ +package io.miragon.bpmn.adapter.outbound.shared + +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.TaskKind +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class BpmnTypeNameTest { + + @Test + fun `maps every task kind to its bpmn element name`() { + val expected = mapOf( + TaskKind.SERVICE to "serviceTask", + TaskKind.USER to "userTask", + TaskKind.RECEIVE to "receiveTask", + TaskKind.SEND to "sendTask", + TaskKind.SCRIPT to "scriptTask", + TaskKind.MANUAL to "manualTask", + TaskKind.BUSINESS_RULE to "businessRuleTask", + TaskKind.NONE to "task", + ) + expected.forEach { (kind, name) -> + assertThat(BpmnTypeName.of(FlowNodeDefinition.Activity.Task(id = "task", kind = kind))).isEqualTo(name) + } + assertThat(expected.keys).containsExactlyInAnyOrder(*TaskKind.entries.toTypedArray()) + } + + @Test + fun `maps every gateway kind to its bpmn element name`() { + val expected = mapOf( + GatewayKind.EXCLUSIVE to "exclusiveGateway", + GatewayKind.PARALLEL to "parallelGateway", + GatewayKind.INCLUSIVE to "inclusiveGateway", + GatewayKind.EVENT_BASED to "eventBasedGateway", + GatewayKind.COMPLEX to "complexGateway", + ) + expected.forEach { (kind, name) -> + assertThat(BpmnTypeName.of(FlowNodeDefinition.Gateway(id = "gw", kind = kind))).isEqualTo(name) + } + assertThat(expected.keys).containsExactlyInAnyOrder(*GatewayKind.entries.toTypedArray()) + } + + @Test + fun `maps every event shape to its bpmn element name`() { + val expected = mapOf( + EventShape.START_EVENT to "startEvent", + EventShape.END_EVENT to "endEvent", + EventShape.INTERMEDIATE_CATCH_EVENT to "intermediateCatchEvent", + EventShape.INTERMEDIATE_THROW_EVENT to "intermediateThrowEvent", + EventShape.BOUNDARY_EVENT to "boundaryEvent", + ) + expected.forEach { (shape, name) -> + assertThat(BpmnTypeName.of(FlowNodeDefinition.Event(id = "event", shape = shape))).isEqualTo(name) + } + assertThat(expected.keys).containsExactlyInAnyOrder(*EventShape.entries.toTypedArray()) + } + + @Test + fun `maps every sub-process kind, keeping event sub-processes as subProcess`() { + val expected = mapOf( + SubProcessKind.PLAIN to "subProcess", + SubProcessKind.EVENT to "subProcess", + SubProcessKind.TRANSACTION to "transaction", + ) + expected.forEach { (kind, name) -> + assertThat(BpmnTypeName.of(FlowNodeDefinition.Activity.SubProcess(id = "sub", kind = kind))).isEqualTo(name) + } + assertThat(expected.keys).containsExactlyInAnyOrder(*SubProcessKind.entries.toTypedArray()) + } + + @Test + fun `maps call activity and unknown nodes`() { + assertThat( + BpmnTypeName.of( + FlowNodeDefinition.Activity.CallActivity(id = "call", definition = CallActivityDefinition("call", "target")), + ), + ).isEqualTo("callActivity") + assertThat(BpmnTypeName.of(FlowNodeDefinition.Unknown(id = "x"))).isEqualTo("unknown") + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/shared/ElementTypeNameTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/shared/ElementTypeNameTest.kt index 0160d5e4..385a5f3a 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/shared/ElementTypeNameTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/adapter/outbound/shared/ElementTypeNameTest.kt @@ -1,9 +1,11 @@ package io.miragon.bpmn.adapter.outbound.shared -import io.miragon.bpmn.domain.shared.BpmnNodeType +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance import io.miragon.bpmn.domain.shared.EventShape -import io.miragon.bpmn.domain.shared.EventDefinitionType +import io.miragon.bpmn.domain.shared.FlowNodeDefinition import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.MessageReference import io.miragon.bpmn.domain.shared.SubProcessKind import io.miragon.bpmn.domain.shared.TaskKind import org.assertj.core.api.Assertions.assertThat @@ -24,7 +26,8 @@ class ElementTypeNameTest { TaskKind.NONE to "TASK", ) expected.forEach { (kind, expectedName) -> - assertThat(ElementTypeName.of(BpmnNodeType.Activity.Task(kind))).isEqualTo(expectedName) + assertThat(ElementTypeName.of(FlowNodeDefinition.Activity.Task(id = "task", kind = kind))) + .isEqualTo(expectedName) } assertThat(expected.keys).containsExactlyInAnyOrder(*TaskKind.entries.toTypedArray()) } @@ -39,7 +42,8 @@ class ElementTypeNameTest { GatewayKind.COMPLEX to "COMPLEX_GATEWAY", ) expected.forEach { (kind, expectedName) -> - assertThat(ElementTypeName.of(BpmnNodeType.Gateway(kind))).isEqualTo(expectedName) + assertThat(ElementTypeName.of(FlowNodeDefinition.Gateway(id = "gw", kind = kind))) + .isEqualTo(expectedName) } assertThat(expected.keys).containsExactlyInAnyOrder(*GatewayKind.entries.toTypedArray()) } @@ -52,14 +56,19 @@ class ElementTypeNameTest { SubProcessKind.TRANSACTION to "TRANSACTION", ) expected.forEach { (kind, expectedName) -> - assertThat(ElementTypeName.of(BpmnNodeType.Activity.SubProcess(kind))).isEqualTo(expectedName) + assertThat(ElementTypeName.of(FlowNodeDefinition.Activity.SubProcess(id = "sub", kind = kind))) + .isEqualTo(expectedName) } assertThat(expected.keys).containsExactlyInAnyOrder(*SubProcessKind.entries.toTypedArray()) } @Test fun `maps call activity to its element-type string`() { - assertThat(ElementTypeName.of(BpmnNodeType.Activity.CallActivity)).isEqualTo("CALL_ACTIVITY") + val callActivity = FlowNodeDefinition.Activity.CallActivity( + id = "call", + definition = CallActivityDefinition("call", "called-process"), + ) + assertThat(ElementTypeName.of(callActivity)).isEqualTo("CALL_ACTIVITY") } @Test @@ -72,31 +81,66 @@ class ElementTypeNameTest { EventShape.BOUNDARY_EVENT to "BOUNDARY_EVENT", ) expected.forEach { (shape, expectedName) -> - assertThat(ElementTypeName.of(BpmnNodeType.Event(shape))).isEqualTo(expectedName) + assertThat(ElementTypeName.of(FlowNodeDefinition.Event(id = "event", shape = shape))) + .isEqualTo(expectedName) } assertThat(expected.keys).containsExactlyInAnyOrder(*EventShape.entries.toTypedArray()) } @Test - fun `prefixes the concrete event definition onto the shape`() { - val expected = mapOf( - EventDefinitionType.TIMER to "TIMER_BOUNDARY_EVENT", - EventDefinitionType.MESSAGE to "MESSAGE_BOUNDARY_EVENT", - EventDefinitionType.ERROR to "ERROR_BOUNDARY_EVENT", - EventDefinitionType.SIGNAL to "SIGNAL_BOUNDARY_EVENT", - EventDefinitionType.ESCALATION to "ESCALATION_BOUNDARY_EVENT", - EventDefinitionType.COMPENSATION to "COMPENSATION_BOUNDARY_EVENT", - EventDefinitionType.NONE to "BOUNDARY_EVENT", + fun `prefixes the concrete event definition onto the shape, shape-only for terminate, conditional and link`() { + // Only timer/message/error/signal/escalation/compensation surface as a prefix; conditional, link and + // terminate render shape-only in the flat Process API vocabulary. + val cases: List> = listOf( + EventDefinitionInstance.Timer() to "TIMER_BOUNDARY_EVENT", + EventDefinitionInstance.Message(MessageReference("m", "m")) to "MESSAGE_BOUNDARY_EVENT", + EventDefinitionInstance.Error("e", "e", "1") to "ERROR_BOUNDARY_EVENT", + EventDefinitionInstance.Signal("s", "s") to "SIGNAL_BOUNDARY_EVENT", + EventDefinitionInstance.Escalation("esc", "esc", "2") to "ESCALATION_BOUNDARY_EVENT", + EventDefinitionInstance.Compensation() to "COMPENSATION_BOUNDARY_EVENT", + EventDefinitionInstance.Conditional("=x") to "BOUNDARY_EVENT", + EventDefinitionInstance.Link("link") to "BOUNDARY_EVENT", + EventDefinitionInstance.Terminate to "BOUNDARY_EVENT", ) - expected.forEach { (definitionType, expectedName) -> - assertThat(ElementTypeName.of(BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, definitionType))) - .isEqualTo(expectedName) + cases.forEach { (definition, expectedName) -> + val event = FlowNodeDefinition.Event( + id = "event", + shape = EventShape.BOUNDARY_EVENT, + eventDefinitions = listOf(definition), + ) + assertThat(ElementTypeName.of(event)).isEqualTo(expectedName) } - assertThat(expected.keys).containsExactlyInAnyOrder(*EventDefinitionType.entries.toTypedArray()) + // every event-definition kind is exercised exactly once + assertThat(cases.map { it.first.type }) + .containsExactlyInAnyOrder(*EventDefinitionInstance.Type.entries.toTypedArray()) + } + + @Test + fun `renders a terminate end event shape-only`() { + val terminateEnd = FlowNodeDefinition.Event( + id = "end", + shape = EventShape.END_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Terminate), + ) + assertThat(ElementTypeName.of(terminateEnd)).isEqualTo("END_EVENT") + } + + @Test + fun `selects the first prefixed event definition when several are present`() { + val event = FlowNodeDefinition.Event( + id = "event", + shape = EventShape.BOUNDARY_EVENT, + eventDefinitions = listOf( + EventDefinitionInstance.Link("link"), + EventDefinitionInstance.Error("e", "e", "1"), + EventDefinitionInstance.Timer(), + ), + ) + assertThat(ElementTypeName.of(event)).isEqualTo("ERROR_BOUNDARY_EVENT") } @Test fun `maps unknown to its element-type string`() { - assertThat(ElementTypeName.of(BpmnNodeType.Unknown)).isEqualTo("UNKNOWN") + assertThat(ElementTypeName.of(FlowNodeDefinition.Unknown(id = "unknown"))).isEqualTo("UNKNOWN") } } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/ExtractProcessModelsServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/ExtractProcessModelsServiceTest.kt new file mode 100644 index 00000000..9e816828 --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/ExtractProcessModelsServiceTest.kt @@ -0,0 +1,88 @@ +package io.miragon.bpmn.application.service + +import io.miragon.bpmn.adapter.inbound.ExtractProcessModelsPlugin +import io.miragon.bpmn.application.port.inbound.ExtractProcessModelsUseCase +import io.miragon.bpmn.domain.BpmnResource +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.ProcessEngine +import io.miragon.bpmn.domain.shared.TaskImplementation +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +class ExtractProcessModelsServiceTest { + + private val underTest = ExtractProcessModelsService() + + @Test + fun `extracts one model per resource, in the order they were given`() { + + // given: two BPMN files targeting the same engine + val resources = listOf(resource("c8-subscribe-newsletter.bpmn"), resource("c8-send-newsletter.bpmn")) + + // when: extracting them + val models = underTest.extractProcessModels(command(resources)) + + // then: each resource yields its own model, and the order is preserved + assertThat(models.map { it.processId }).containsExactly("newsletterSubscription", "sendNewsletter") + } + + @Test + fun `the models carry what the engine dialect resolved`() { + + // given: the Camunda 8 model + val models = underTest.extractProcessModels(command(listOf(resource("c8-subscribe-newsletter.bpmn")))) + + // then: extraction really ran — a job type only the Zeebe dialect produces is present + val implementations = models.single().allFlowNodes + .filterIsInstance() + .mapNotNull { it.implementation } + assertThat(implementations).contains(TaskImplementation.JobWorker("newsletter.sendConfirmationMail")) + } + + @Test + fun `no resources means no models`() { + + // when / then: an empty input is not an error + assertThat(underTest.extractProcessModels(command(emptyList()))).isEmpty() + } + + @Test + fun `the plugin wires the use case by default`() { + + // when: going through the inbound entry point without injecting anything + val models = ExtractProcessModelsPlugin().execute( + listOf(resource("c8-subscribe-newsletter.bpmn")), + ProcessEngine.ZEEBE, + ) + + // then + assertThat(models.single().processId).isEqualTo("newsletterSubscription") + } + + @Test + fun `a broken resource fails with the file that caused it`() { + + // given: one good file and one that is not XML + val resources = listOf( + resource("c8-subscribe-newsletter.bpmn"), + BpmnResource(fileName = "broken.bpmn", content = ") = ExtractProcessModelsUseCase.Command( + resources = resources, + engine = ProcessEngine.ZEEBE, + ) + + private fun resource(fileName: String): BpmnResource { + val url = requireNotNull(javaClass.getResource("/bpmn/$fileName")) + return BpmnResource(fileName = fileName, content = File(url.toURI()).readBytes()) + } +} diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiDeterministicOrderTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiDeterministicOrderTest.kt index 51849579..ab804ff4 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiDeterministicOrderTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiDeterministicOrderTest.kt @@ -1,15 +1,16 @@ package io.miragon.bpmn.application.service import io.miragon.bpmn.adapter.outbound.codegen.CodeGenerationAdapter -import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort -import io.miragon.bpmn.domain.BpmnModel -import io.miragon.bpmn.domain.BpmnResource import io.miragon.bpmn.application.port.inbound.GenerateProcessApiInMemoryUseCase import io.miragon.bpmn.application.port.inbound.GenerateProcessApiInMemoryUseCase.BpmnInput +import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort +import io.miragon.bpmn.domain.BpmnResource import io.miragon.bpmn.domain.GeneratedApiFile +import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.OutputLanguage import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testSendNewsletterBpmnModel +import io.miragon.bpmn.domain.testSendNewsletterModel +import io.miragon.bpmn.domain.withDisplayName import io.mockk.every import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat @@ -33,12 +34,12 @@ class GenerateProcessApiDeterministicOrderTest { // emitted variant blocks and the merged base node differ — a reorder would change bytes if unfixed. private val variantNames = listOf("staging", "dev", "prod") - private val modelsByName: Map = variantNames.associateWith { name -> - testSendNewsletterBpmnModel(processId = "sendNewsletter", variantName = name) + private val modelsByName: Map = variantNames.associateWith { name -> + testSendNewsletterModel(processId = "sendNewsletter", variantName = name) .let { model -> model.copy( flowNodes = model.flowNodes.map { node -> - if (node.id == "serviceTask_loadSubscribers") node.copy(displayName = "load-$name") else node + if (node.id == "serviceTask_loadSubscribers") node.withDisplayName("load-$name") else node }, ) } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiInMemoryServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiInMemoryServiceTest.kt index 5ab61096..90901e9e 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiInMemoryServiceTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiInMemoryServiceTest.kt @@ -3,8 +3,8 @@ package io.miragon.bpmn.application.service import io.miragon.bpmn.application.port.inbound.GenerateProcessApiInMemoryUseCase import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort import io.miragon.bpmn.application.port.outbound.GenerateApiCodePort -import io.miragon.bpmn.domain.BpmnModel import io.miragon.bpmn.domain.GeneratedApiFile +import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.OutputLanguage import io.miragon.bpmn.domain.shared.ProcessEngine import io.miragon.bpmn.domain.validation.BpmnValidationException @@ -85,11 +85,8 @@ class GenerateProcessApiInMemoryServiceTest { verify(exactly = 0) { codeGenerator.generateCode(any()) } } - private val dummyModel = BpmnModel( + private val dummyModel = ProcessModel( processId = "testProcess", flowNodes = emptyList(), - messages = emptyList(), - signals = emptyList(), - errors = emptyList(), ) } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiServiceTest.kt index ff5e2bf0..aca81403 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiServiceTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessApiServiceTest.kt @@ -1,17 +1,17 @@ package io.miragon.bpmn.application.service import io.miragon.bpmn.adapter.outbound.filesystem.ProcessApiFileSaver -import io.miragon.bpmn.domain.BpmnFileResult import io.miragon.bpmn.application.port.inbound.GenerateProcessApiFromFilesystemUseCase import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort import io.miragon.bpmn.application.port.outbound.GenerateApiCodePort import io.miragon.bpmn.application.port.outbound.LoadBpmnFilesPort -import io.miragon.bpmn.domain.BpmnModel +import io.miragon.bpmn.domain.BpmnFileResult import io.miragon.bpmn.domain.BpmnResource import io.miragon.bpmn.domain.GeneratedApiFile +import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.shared.OutputLanguage import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModelApi +import io.miragon.bpmn.domain.testProcessModelApi import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk @@ -134,20 +134,14 @@ class GenerateProcessApiServiceTest { verify { fileSystemOutput.writeFiles(emptyList(), "outputFolder") } } - private val dummyModel = BpmnModel( + private val dummyModel = ProcessModel( processId = "newsletterSubscription", flowNodes = emptyList(), - messages = emptyList(), - signals = emptyList(), - errors = emptyList(), ) - private val nonExecutableModel = BpmnModel( + private val nonExecutableModel = ProcessModel( processId = "draftProcess", flowNodes = emptyList(), - messages = emptyList(), - signals = emptyList(), - errors = emptyList(), isExecutable = false, ) @@ -160,7 +154,7 @@ class GenerateProcessApiServiceTest { packagePath = "de.emaarco.example", ) - private fun getExpectedModelApi() = testBpmnModelApi( + private fun getExpectedModelApi() = testProcessModelApi( model = dummyModel, packagePath = "de.emaarco.example", language = OutputLanguage.KOTLIN diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessJsonInMemoryServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessJsonInMemoryServiceTest.kt index 3784294e..5b5ccae2 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessJsonInMemoryServiceTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessJsonInMemoryServiceTest.kt @@ -5,7 +5,7 @@ import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort import io.miragon.bpmn.application.port.outbound.GenerateJsonPort import io.miragon.bpmn.domain.GeneratedJsonFile import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk @@ -50,5 +50,5 @@ class GenerateProcessJsonInMemoryServiceTest { confirmVerified(jsonGenerator, bpmnExtractor) } - private val dummyModel = testBpmnModel() + private val dummyModel = testProcessModel() } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessJsonServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessJsonServiceTest.kt index 10156c88..3b31fb91 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessJsonServiceTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/GenerateProcessJsonServiceTest.kt @@ -8,7 +8,7 @@ import io.miragon.bpmn.application.port.outbound.SaveProcessJsonPort import io.miragon.bpmn.domain.BpmnResource import io.miragon.bpmn.domain.GeneratedJsonFile import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk @@ -55,5 +55,5 @@ class GenerateProcessJsonServiceTest { confirmVerified(jsonGenerator, bpmnFileLoader, fileSaver) } - private val dummyModel = testBpmnModel() + private val dummyModel = testProcessModel() } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/ValidateBpmnServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/ValidateBpmnServiceTest.kt index 92d582db..ba743530 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/ValidateBpmnServiceTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/application/service/ValidateBpmnServiceTest.kt @@ -5,10 +5,10 @@ import io.miragon.bpmn.application.port.outbound.ExtractBpmnPort import io.miragon.bpmn.application.port.outbound.LoadBpmnFilesPort import io.miragon.bpmn.domain.BpmnResource import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.mockk.every import io.mockk.mockk @@ -38,7 +38,7 @@ class ValidateBpmnServiceTest { // given: a valid model whose detected engine matches the selected one every { bpmnFileLoader.loadFrom(any(), any()) } returns listOf(dummyResource) - every { bpmnExtractor.extract(any(), any()) } returns testBpmnModel(detectedEngine = ProcessEngine.ZEEBE) + every { bpmnExtractor.extract(any(), any()) } returns testProcessModel(detectedEngine = ProcessEngine.ZEEBE) // when: validateBpmn is called val result = underTest.validateBpmn(command) @@ -52,11 +52,12 @@ class ValidateBpmnServiceTest { fun `pre-merge error stops execution and returns early`() { // given: a model with a service task missing implementation (pre-merge ERROR) - val invalidModel = testBpmnModel( + val invalidModel = testProcessModel( flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Activity.Task( id = "task1", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition(id = "task1")), + kind = TaskKind.SERVICE, + implementation = TaskImplementation.Unspecified, ) ) ) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/BpmnModelApiTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/BpmnModelApiTest.kt index 7a92d92b..f5a33aea 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/BpmnModelApiTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/BpmnModelApiTest.kt @@ -19,8 +19,8 @@ class BpmnModelApiTest { // when / then: all separator variants produce the same file name processIds.forEach { id -> - val model = testBpmnModel(processId = id) - val api = testBpmnModelApi(model = model) + val model = testProcessModel(processId = id) + val api = testProcessModelApi(model = model) assertThat(api.fileName()).isEqualTo(expectedFileName) } } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/TestBpmnModel.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/TestBpmnModel.kt deleted file mode 100644 index 99c8a800..00000000 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/TestBpmnModel.kt +++ /dev/null @@ -1,304 +0,0 @@ -package io.miragon.bpmn.domain - -import io.miragon.bpmn.domain.shared.SubProcessKind -import io.miragon.bpmn.domain.shared.BpmnNodeType -import io.miragon.bpmn.domain.shared.EventShape -import io.miragon.bpmn.domain.shared.EventDefinitionType -import io.miragon.bpmn.domain.shared.GatewayKind -import io.miragon.bpmn.domain.shared.TaskKind -import io.miragon.bpmn.domain.shared.CallActivityDefinition -import io.miragon.bpmn.domain.shared.ErrorDefinition -import io.miragon.bpmn.domain.shared.CompensationDefinition -import io.miragon.bpmn.domain.shared.CompensationType -import io.miragon.bpmn.domain.shared.EscalationDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_AFTER_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.ASYNC_BEFORE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeDefinition.Companion.EXCLUSIVE_KEY -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition -import io.miragon.bpmn.domain.shared.EventDirection -import io.miragon.bpmn.domain.shared.OutputLanguage -import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_VALUE_KEY -import io.miragon.bpmn.domain.shared.SignalDefinition -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.VariableDefinition -import io.miragon.bpmn.domain.shared.VariableDirection - -fun testBpmnModel( - processId: String = "order", - variantName: String? = null, - flowNodes: List = listOf(FlowNodeDefinition(id = "create-order")), - sequenceFlows: List = emptyList(), - messages: List = listOf(MessageDefinition(id = "messageId", name = "messageName")), - signals: List = listOf(SignalDefinition(id = "signalId", name = "signalName")), - errors: List = listOf(ErrorDefinition(id = "errorId", name = "errorName", code = "errorCode")), - escalations: List = emptyList(), - compensations: List = emptyList(), - detectedEngine: ProcessEngine? = null, -) = BpmnModel( - processId = processId, - variantName = variantName, - flowNodes = flowNodes, - sequenceFlows = sequenceFlows, - messages = messages, - signals = signals, - errors = errors, - escalations = escalations, - compensations = compensations, - detectedEngine = detectedEngine, -) - -fun testBpmnModelApi( - model: ProcessModel = testBpmnModel(), - packagePath: String = "packagePath", - language: OutputLanguage = OutputLanguage.KOTLIN, - engine: ProcessEngine = ProcessEngine.ZEEBE, -) = BpmnModelApi( - model = model, - packagePath = packagePath, - outputLanguage = language, - engine = engine, -) - -fun testSubscribeNewsletterBpmnModel( - processId: String = "newsletterSubscription", - variantName: String? = null, - flowNodes: List = listOf( - FlowNodeDefinition("CallActivity_AbortRegistration", BpmnNodeType.Activity.CallActivity, - displayName = "Abort registration", - properties = FlowNodeProperties.CallActivity(CallActivityDefinition("CallActivity_AbortRegistration", "abort-registration")), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)), - previousElements = listOf("Timer_After3Days"), followingElements = listOf("CompensationEndEvent_RegistrationAborted")), - FlowNodeDefinition("Activity_ConfirmRegistration", BpmnNodeType.Activity.Task(TaskKind.USER), - displayName = "Confirm subscription", - attachedElements = listOf("Timer_EveryDay"), - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_SendConfirmationMail"), followingElements = listOf("EndEvent_SubscriptionConfirmed")), - FlowNodeDefinition("Activity_SendConfirmationMail", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Send confirmation mail", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_SendConfirmationMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendConfirmationMail"))), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)), - parentId = "SubProcess_Confirmation", - previousElements = listOf("StartEvent_RequestReceived", "Timer_EveryDay"), followingElements = listOf("Activity_ConfirmRegistration")), - FlowNodeDefinition("Activity_SendWelcomeMail", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Send Welcome-Mail", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_SendWelcomeMail", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendWelcomeMail"))), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)), - previousElements = listOf("Gateway_SplitNotifications"), followingElements = listOf("Gateway_JoinNotifications"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, ASYNC_AFTER_KEY to true, EXCLUSIVE_KEY to false)), - FlowNodeDefinition("Activity_NotifyCommunity", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Notify community", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("Activity_NotifyCommunity", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.notifyCommunity"))), - previousElements = listOf("Gateway_SplitNotifications"), followingElements = listOf("Gateway_JoinNotifications"), - engineSpecificProperties = mapOf(ASYNC_BEFORE_KEY to true, ASYNC_AFTER_KEY to true, EXCLUSIVE_KEY to false)), - FlowNodeDefinition("Gateway_SplitNotifications", BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("SubProcess_Confirmation"), followingElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity")), - FlowNodeDefinition("Gateway_JoinNotifications", BpmnNodeType.Gateway(GatewayKind.PARALLEL), - previousElements = listOf("Activity_SendWelcomeMail", "Activity_NotifyCommunity"), followingElements = listOf("EndEvent_RegistrationCompleted")), - FlowNodeDefinition("CompensationEndEvent_RegistrationAborted", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.COMPENSATION), - displayName = "Registration aborted", - previousElements = listOf("CallActivity_AbortRegistration")), - FlowNodeDefinition("CompensationEvent_OnSubscriptionCounter", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.COMPENSATION), - displayName = "Registration aborted", - attachedToRef = "serviceTask_incrementSubscriptionCounter", interrupting = true), - FlowNodeDefinition("CompensationTask_DecrementSubscriptionCounter", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Decrement subscription counter", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("CompensationTask_DecrementSubscriptionCounter", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "counterClass")))), - FlowNodeDefinition("EndEvent_RegistrationCompleted", BpmnNodeType.Event(EventShape.END_EVENT), - displayName = "Registration completed", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("EndEvent_RegistrationCompleted", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.registrationCompleted"))), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), - previousElements = listOf("Gateway_JoinNotifications")), - FlowNodeDefinition("EndEvent_RegistrationNotPossible", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.SIGNAL), - displayName = "Registration not possible", - properties = FlowNodeProperties.SignalEvent("Signal_RegistrationNotPossible", EventDirection.THROW), - previousElements = listOf("ErrorEvent_InvalidMail")), - FlowNodeDefinition("EndEvent_SubscriptionConfirmed", BpmnNodeType.Event(EventShape.END_EVENT), - displayName = "Subscription confirmed", - parentId = "SubProcess_Confirmation", - previousElements = listOf("Activity_ConfirmRegistration")), - FlowNodeDefinition("ErrorEvent_InvalidMail", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.ERROR), - displayName = "Invalid Mail", - attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("EndEvent_RegistrationNotPossible")), - FlowNodeDefinition("serviceTask_incrementSubscriptionCounter", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - displayName = "Increment subscription counter", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("serviceTask_incrementSubscriptionCounter", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "counterClass"))), - attachedElements = listOf("CompensationEvent_OnSubscriptionCounter"), - previousElements = listOf("StartEvent_SubmitRegistrationForm"), followingElements = listOf("SubProcess_Confirmation")), - FlowNodeDefinition("StartEvent_RequestReceived", BpmnNodeType.Event(EventShape.START_EVENT), - displayName = "Subscription requested", - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail")), - FlowNodeDefinition("StartEvent_SubmitRegistrationForm", BpmnNodeType.Event(EventShape.START_EVENT, EventDefinitionType.MESSAGE), - displayName = "Submit newsletter form", - properties = FlowNodeProperties.MessageEvent("Message_FormSubmitted", EventDirection.CATCH), - variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), - followingElements = listOf("serviceTask_incrementSubscriptionCounter")), - FlowNodeDefinition("SubProcess_Confirmation", BpmnNodeType.Activity.SubProcess(SubProcessKind.PLAIN), - displayName = "Subscription Confirmation", - attachedElements = listOf("ErrorEvent_InvalidMail", "Timer_After3Days"), - previousElements = listOf("serviceTask_incrementSubscriptionCounter"), followingElements = listOf("Gateway_SplitNotifications")), - FlowNodeDefinition("Timer_After3Days", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - displayName = "After 3 days", - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_After3Days", "Duration", "$" + "{testVariable}")), - attachedToRef = "SubProcess_Confirmation", interrupting = true, - followingElements = listOf("CallActivity_AbortRegistration")), - FlowNodeDefinition("Timer_EveryDay", BpmnNodeType.Event(EventShape.BOUNDARY_EVENT, EventDefinitionType.TIMER), - displayName = "Every day", - properties = FlowNodeProperties.Timer(TimerDefinition("Timer_EveryDay", "Duration", "PT1M")), - attachedToRef = "Activity_ConfirmRegistration", interrupting = false, - parentId = "SubProcess_Confirmation", - followingElements = listOf("Activity_SendConfirmationMail")), - ), - sequenceFlows: List = listOf( - SequenceFlowDefinition("Flow_05i3x1y", "StartEvent_RequestReceived", "Activity_SendConfirmationMail"), - SequenceFlowDefinition("Flow_09cuvzp", "SubProcess_Confirmation", "Gateway_SplitNotifications"), - SequenceFlowDefinition("Flow_0i2ctuv", "ErrorEvent_InvalidMail", "EndEvent_RegistrationNotPossible"), - SequenceFlowDefinition("Flow_0x4ewvb", "Timer_EveryDay", "Activity_SendConfirmationMail"), - SequenceFlowDefinition("Flow_0zdmt0t", "serviceTask_incrementSubscriptionCounter", "SubProcess_Confirmation"), - SequenceFlowDefinition("Flow_16hub0n", "Gateway_SplitNotifications", "Activity_SendWelcomeMail"), - SequenceFlowDefinition("Flow_1862jd8", "Gateway_JoinNotifications", "EndEvent_RegistrationCompleted"), - SequenceFlowDefinition("Flow_1bckm43", "Activity_SendConfirmationMail", "Activity_ConfirmRegistration"), - SequenceFlowDefinition("Flow_1bsb8no", "CallActivity_AbortRegistration", "CompensationEndEvent_RegistrationAborted"), - SequenceFlowDefinition("Flow_1cpwe57", "Activity_ConfirmRegistration", "EndEvent_SubscriptionConfirmed"), - SequenceFlowDefinition("Flow_1csfyyz", "StartEvent_SubmitRegistrationForm", "serviceTask_incrementSubscriptionCounter"), - SequenceFlowDefinition("Flow_1duwy83", "Activity_NotifyCommunity", "Gateway_JoinNotifications"), - SequenceFlowDefinition("Flow_1i7hjid", "Activity_SendWelcomeMail", "Gateway_JoinNotifications"), - SequenceFlowDefinition("Flow_1l1lj4m", "Timer_After3Days", "CallActivity_AbortRegistration"), - SequenceFlowDefinition("Flow_1p5t47z", "Gateway_SplitNotifications", "Activity_NotifyCommunity"), - ), - messages: List = listOf( - MessageDefinition("StartEvent_SubmitRegistrationForm", "Message_FormSubmitted"), - ), - signals: List = listOf( - SignalDefinition("EndEvent_RegistrationNotPossible", "Signal_RegistrationNotPossible") - ), - errors: List = listOf( - ErrorDefinition("ErrorEvent_InvalidMail", "Error_InvalidMail", "500") - ), - escalations: List = emptyList(), - compensations: List = listOf( - CompensationDefinition("CompensationEndEvent_RegistrationAborted", CompensationType.THROWING), - CompensationDefinition("CompensationEvent_OnSubscriptionCounter", CompensationType.CATCHING), - ), - detectedEngine: ProcessEngine? = null, -) = testBpmnModel( - processId = processId, - variantName = variantName, - flowNodes = flowNodes, - sequenceFlows = sequenceFlows, - messages = messages, - signals = signals, - errors = errors, - escalations = escalations, - compensations = compensations, - detectedEngine = detectedEngine, -) - -fun testSendNewsletterBpmnModel( - processId: String = "sendNewsletter", - variantName: String? = null, - flowNodes: List = listOf( - // Main flow - FlowNodeDefinition("startEvent_editionCreated", BpmnNodeType.Event(EventShape.START_EVENT), - followingElements = listOf("serviceTask_loadSubscribers")), - FlowNodeDefinition("serviceTask_loadSubscribers", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("serviceTask_loadSubscribers", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.loadSubscribers"))), - variables = listOf(VariableDefinition("subscribers", VariableDirection.OUTPUT), VariableDefinition("author", VariableDirection.OUTPUT)), - previousElements = listOf("startEvent_editionCreated"), followingElements = listOf("gateway_hasSubscribers")), - FlowNodeDefinition("gateway_hasSubscribers", BpmnNodeType.Gateway(GatewayKind.EXCLUSIVE), - previousElements = listOf("serviceTask_loadSubscribers"), followingElements = listOf("serviceTask_sendToSubscriber", "endEvent_noSubscribers")), - FlowNodeDefinition("serviceTask_sendToSubscriber", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("serviceTask_sendToSubscriber", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendMailToSubscriber"))), - previousElements = listOf("gateway_hasSubscribers"), followingElements = listOf("serviceTask_notifyAuthor")), - FlowNodeDefinition("serviceTask_notifyAuthor", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("serviceTask_notifyAuthor", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.notifyAuthor"))), - previousElements = listOf("serviceTask_sendToSubscriber"), followingElements = listOf("endEvent_editionSent")), - FlowNodeDefinition("endEvent_editionSent", BpmnNodeType.Event(EventShape.END_EVENT), - previousElements = listOf("serviceTask_notifyAuthor")), - FlowNodeDefinition("endEvent_noSubscribers", BpmnNodeType.Event(EventShape.END_EVENT), - previousElements = listOf("gateway_hasSubscribers")), - // Event subprocess: error handling - FlowNodeDefinition("eventSubProcess_errorHandling", BpmnNodeType.Activity.SubProcess(SubProcessKind.PLAIN)), - FlowNodeDefinition("event_mailRejected", BpmnNodeType.Event(EventShape.START_EVENT, EventDefinitionType.MESSAGE), - properties = FlowNodeProperties.MessageEvent("Message_MailRejected", EventDirection.CATCH), - parentId = "eventSubProcess_errorHandling", interrupting = true, - followingElements = listOf("serviceTask_analyzeError")), - FlowNodeDefinition("serviceTask_analyzeError", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("serviceTask_analyzeError", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.analyzeSendError"))), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("event_mailRejected"), followingElements = listOf("gateway_canSendAgain")), - FlowNodeDefinition("gateway_canSendAgain", BpmnNodeType.Gateway(GatewayKind.EXCLUSIVE), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("serviceTask_analyzeError"), followingElements = listOf("serviceTask_sendMailAgain", "escalationEndEvent_nofitySupport")), - FlowNodeDefinition("serviceTask_sendMailAgain", BpmnNodeType.Activity.Task(TaskKind.SERVICE), - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition("serviceTask_sendMailAgain", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendMailToSubscriber"))), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("gateway_canSendAgain"), followingElements = listOf("eventGateway_afterSendingAgain")), - FlowNodeDefinition("eventGateway_afterSendingAgain", BpmnNodeType.Gateway(GatewayKind.EVENT_BASED), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("serviceTask_sendMailAgain"), followingElements = listOf("timer_noRejectionForOneDay", "event_mailRejectedAgain")), - FlowNodeDefinition("timer_noRejectionForOneDay", BpmnNodeType.Event(EventShape.INTERMEDIATE_CATCH_EVENT, EventDefinitionType.TIMER), - properties = FlowNodeProperties.Timer(TimerDefinition("timer_noRejectionForOneDay", "Duration", "PT1D")), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("eventGateway_afterSendingAgain"), followingElements = listOf("endEvent_issueResolved")), - FlowNodeDefinition("escalationEndEvent_nofitySupport", BpmnNodeType.Event(EventShape.END_EVENT, EventDefinitionType.ESCALATION), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("gateway_canSendAgain")), - FlowNodeDefinition("event_mailRejectedAgain", BpmnNodeType.Event(EventShape.INTERMEDIATE_CATCH_EVENT, EventDefinitionType.MESSAGE), - properties = FlowNodeProperties.MessageEvent("Message_MailRejectedAgain", EventDirection.CATCH), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("eventGateway_afterSendingAgain"), followingElements = listOf("escalationEndEvent_nofitySupportAfterRepeatedError")), - FlowNodeDefinition("escalationEndEvent_nofitySupportAfterRepeatedError", BpmnNodeType.Event(EventShape.END_EVENT), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("event_mailRejectedAgain")), - FlowNodeDefinition("endEvent_issueResolved", BpmnNodeType.Event(EventShape.END_EVENT), - parentId = "eventSubProcess_errorHandling", - previousElements = listOf("timer_noRejectionForOneDay")), - ), - sequenceFlows: List = listOf( - // Main flow - SequenceFlowDefinition("Flow_0bianz5", "startEvent_editionCreated", "serviceTask_loadSubscribers"), - SequenceFlowDefinition("Flow_04andb8", "serviceTask_loadSubscribers", "gateway_hasSubscribers"), - SequenceFlowDefinition("Flow_1jogut0", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", flowName = "Yes", isDefault = true), - SequenceFlowDefinition("Flow_1gsz7wd", "gateway_hasSubscribers", "endEvent_noSubscribers", flowName = "No", conditionExpression = "\${subscribers.size() > 0}"), - SequenceFlowDefinition("Flow_1ruayvl", "serviceTask_sendToSubscriber", "serviceTask_notifyAuthor"), - SequenceFlowDefinition("Flow_0v2v55n", "serviceTask_notifyAuthor", "endEvent_editionSent"), - // Event subprocess - SequenceFlowDefinition("Flow_0vtppnk", "event_mailRejected", "serviceTask_analyzeError"), - SequenceFlowDefinition("Flow_13nmnag", "serviceTask_analyzeError", "gateway_canSendAgain"), - SequenceFlowDefinition("Flow_1izucof", "gateway_canSendAgain", "serviceTask_sendMailAgain", flowName = "Yes", isDefault = true), - SequenceFlowDefinition("Flow_18nf2jh", "gateway_canSendAgain", "escalationEndEvent_nofitySupport", flowName = "No", conditionExpression = "\${rejection.reason == \"PERMANENT\"}"), - SequenceFlowDefinition("Flow_0vym6nu", "serviceTask_sendMailAgain", "eventGateway_afterSendingAgain"), - SequenceFlowDefinition("Flow_0enjkoe", "eventGateway_afterSendingAgain", "timer_noRejectionForOneDay"), - SequenceFlowDefinition("Flow_081cykl", "eventGateway_afterSendingAgain", "event_mailRejectedAgain"), - SequenceFlowDefinition("Flow_0x9thpq", "event_mailRejectedAgain", "escalationEndEvent_nofitySupportAfterRepeatedError"), - SequenceFlowDefinition("Flow_0338xzf", "timer_noRejectionForOneDay", "endEvent_issueResolved"), - ), - messages: List = listOf( - MessageDefinition("event_mailRejected", "Message_MailRejected", engineSpecificProperties = mapOf("customExtension" to "customValue")), - MessageDefinition("event_mailRejectedAgain", "Message_MailRejectedAgain"), - ), - signals: List = emptyList(), - errors: List = emptyList(), - escalations: List = listOf( - EscalationDefinition("escalationEndEvent_nofitySupport", "escalation_notifySupport", "200"), - ), - compensations: List = emptyList(), -) = testBpmnModel( - processId = processId, - variantName = variantName, - flowNodes = flowNodes, - sequenceFlows = sequenceFlows, - messages = messages, - signals = signals, - errors = errors, - escalations = escalations, - compensations = compensations, -) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/TestProcessModel.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/TestProcessModel.kt new file mode 100644 index 00000000..96764914 --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/TestProcessModel.kt @@ -0,0 +1,498 @@ +package io.miragon.bpmn.domain + +import io.miragon.bpmn.domain.shared.CallActivityDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.GatewayKind +import io.miragon.bpmn.domain.shared.MessageReference +import io.miragon.bpmn.domain.shared.OutputLanguage +import io.miragon.bpmn.domain.shared.ProcessEngine +import io.miragon.bpmn.domain.shared.RootElementDefinition +import io.miragon.bpmn.domain.shared.RootElements +import io.miragon.bpmn.domain.shared.SequenceFlowDefinition +import io.miragon.bpmn.domain.shared.SubProcessKind +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.shared.TimerType +import io.miragon.bpmn.domain.shared.VariableDefinition +import io.miragon.bpmn.domain.shared.VariableDirection + +fun testProcessModel( + processId: String = "order", + processName: String? = null, + variantName: String? = null, + flowNodes: List = listOf(FlowNodeDefinition.Unknown(id = "create-order")), + sequenceFlows: List = emptyList(), + messages: List = listOf(RootElementDefinition.Message(id = "messageId", name = "messageName")), + signals: List = listOf(RootElementDefinition.Signal(id = "signalId", name = "signalName")), + errors: List = listOf(RootElementDefinition.Error(id = "errorId", name = "errorName", code = "errorCode")), + escalations: List = emptyList(), + detectedEngine: ProcessEngine? = null, + variants: List = emptyList(), +) = ProcessModel( + processId = processId, + processName = processName, + variantName = variantName, + flowNodes = flowNodes, + sequenceFlows = sequenceFlows, + definitions = RootElements(messages, signals, errors, escalations), + detectedEngine = detectedEngine, + variants = variants, +) + +fun testProcessModelApi( + model: ProcessModel = testProcessModel(), + packagePath: String = "packagePath", + language: OutputLanguage = OutputLanguage.KOTLIN, + engine: ProcessEngine = ProcessEngine.ZEEBE, +) = BpmnModelApi( + model = model, + packagePath = packagePath, + outputLanguage = language, + targetEngine = engine, +) + +/** + * Polymorphic copy of a [FlowNodeDefinition] with a new [id], across the sealed hierarchy. + */ +fun FlowNodeDefinition.withId(id: String?): FlowNodeDefinition = when (this) { + is FlowNodeDefinition.Gateway -> copy(id = id) + is FlowNodeDefinition.Event -> copy(id = id) + is FlowNodeDefinition.Activity.Task -> copy(id = id) + is FlowNodeDefinition.Activity.SubProcess -> copy(id = id) + is FlowNodeDefinition.Activity.CallActivity -> copy(id = id) + is FlowNodeDefinition.Unknown -> copy(id = id) +} + +/** + * Polymorphic copy of a [FlowNodeDefinition] with a new [displayName], across the sealed hierarchy. + */ +fun FlowNodeDefinition.withDisplayName(displayName: String?): FlowNodeDefinition = when (this) { + is FlowNodeDefinition.Gateway -> copy(displayName = displayName) + is FlowNodeDefinition.Event -> copy(displayName = displayName) + is FlowNodeDefinition.Activity.Task -> copy(displayName = displayName) + is FlowNodeDefinition.Activity.SubProcess -> copy(displayName = displayName) + is FlowNodeDefinition.Activity.CallActivity -> copy(displayName = displayName) + is FlowNodeDefinition.Unknown -> copy(displayName = displayName) +} + +/** + * Convenience builder for a service task backed by a Zeebe job worker. + */ +fun jobWorkerTask( + id: String, + jobType: String, + displayName: String? = null, + incoming: List = emptyList(), + outgoing: List = emptyList(), + variables: List = emptyList(), + boundaryEventRefs: List = emptyList(), + engineAttributes: Map = emptyMap(), +) = FlowNodeDefinition.Activity.Task( + id = id, + kind = TaskKind.SERVICE, + displayName = displayName, + incoming = incoming, + outgoing = outgoing, + implementation = TaskImplementation.JobWorker(jobType), + boundaryEventRefs = boundaryEventRefs, + variables = variables, + engineAttributes = engineAttributes, +) + +private val asyncAttributes = mapOf( + "camunda:asyncBefore" to true, + "camunda:asyncAfter" to true, + "camunda:exclusive" to false, +) + +@Suppress("LongParameterList") +fun testSubscribeNewsletterModel( + processId: String = "newsletterSubscription", + processName: String? = null, + variantName: String? = null, + flowNodes: List = subscribeNewsletterFlowNodes(), + sequenceFlows: List = subscribeNewsletterSequenceFlows(), + messages: List = listOf( + RootElementDefinition.Message("Message_FormSubmitted", "Message_FormSubmitted"), + ), + signals: List = listOf( + RootElementDefinition.Signal("Signal_RegistrationNotPossible", "Signal_RegistrationNotPossible"), + ), + errors: List = listOf( + RootElementDefinition.Error("Error_InvalidMail", "Error_InvalidMail", "500"), + ), + escalations: List = emptyList(), + detectedEngine: ProcessEngine? = null, +) = testProcessModel( + processId = processId, + processName = processName, + variantName = variantName, + flowNodes = flowNodes, + sequenceFlows = sequenceFlows, + messages = messages, + signals = signals, + errors = errors, + escalations = escalations, + detectedEngine = detectedEngine, +) + +/** + * Root-scope nodes of the newsletter-subscription process; the confirmation sub-process owns its own. + */ +fun subscribeNewsletterFlowNodes(): List = listOf( + FlowNodeDefinition.Activity.CallActivity( + id = "CallActivity_AbortRegistration", + definition = CallActivityDefinition("CallActivity_AbortRegistration", "abort-registration"), + displayName = "Abort registration", + incoming = listOf("Flow_1l1lj4m"), + outgoing = listOf("Flow_1bsb8no"), + variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)), + ), + jobWorkerTask( + id = "Activity_SendWelcomeMail", + jobType = "newsletter.sendWelcomeMail", + displayName = "Send Welcome-Mail", + incoming = listOf("Flow_16hub0n"), + outgoing = listOf("Flow_1i7hjid"), + variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)), + engineAttributes = asyncAttributes, + ), + jobWorkerTask( + id = "Activity_NotifyCommunity", + jobType = "newsletter.notifyCommunity", + displayName = "Notify community", + incoming = listOf("Flow_1p5t47z"), + outgoing = listOf("Flow_1duwy83"), + engineAttributes = asyncAttributes, + ), + FlowNodeDefinition.Gateway( + id = "Gateway_SplitNotifications", + kind = GatewayKind.PARALLEL, + incoming = listOf("Flow_09cuvzp"), + outgoing = listOf("Flow_16hub0n", "Flow_1p5t47z"), + ), + FlowNodeDefinition.Gateway( + id = "Gateway_JoinNotifications", + kind = GatewayKind.PARALLEL, + incoming = listOf("Flow_1i7hjid", "Flow_1duwy83"), + outgoing = listOf("Flow_1862jd8"), + ), + FlowNodeDefinition.Event( + id = "CompensationEndEvent_RegistrationAborted", + shape = EventShape.END_EVENT, + displayName = "Registration aborted", + incoming = listOf("Flow_1bsb8no"), + eventDefinitions = listOf(EventDefinitionInstance.Compensation()), + ), + FlowNodeDefinition.Event( + id = "CompensationEvent_OnSubscriptionCounter", + shape = EventShape.BOUNDARY_EVENT, + displayName = "Registration aborted", + attachedToRef = "serviceTask_incrementSubscriptionCounter", + interrupting = true, + eventDefinitions = listOf(EventDefinitionInstance.Compensation()), + ), + FlowNodeDefinition.Activity.Task( + id = "CompensationTask_DecrementSubscriptionCounter", + kind = TaskKind.NONE, + displayName = "Decrement subscription counter", + ), + FlowNodeDefinition.Event( + id = "EndEvent_RegistrationCompleted", + shape = EventShape.END_EVENT, + displayName = "Registration completed", + incoming = listOf("Flow_1862jd8"), + implementation = TaskImplementation.JobWorker("newsletter.registrationCompleted"), + variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), + ), + FlowNodeDefinition.Event( + id = "EndEvent_RegistrationNotPossible", + shape = EventShape.END_EVENT, + displayName = "Registration not possible", + incoming = listOf("Flow_0i2ctuv"), + eventDefinitions = listOf( + EventDefinitionInstance.Signal("Signal_RegistrationNotPossible", "Signal_RegistrationNotPossible"), + ), + ), + FlowNodeDefinition.Event( + id = "ErrorEvent_InvalidMail", + shape = EventShape.BOUNDARY_EVENT, + displayName = "Invalid Mail", + attachedToRef = "SubProcess_Confirmation", + interrupting = true, + outgoing = listOf("Flow_0i2ctuv"), + eventDefinitions = listOf(EventDefinitionInstance.Error("Error_InvalidMail", "Error_InvalidMail", "500")), + ), + jobWorkerTask( + id = "serviceTask_incrementSubscriptionCounter", + jobType = "counterClass", + displayName = "Increment subscription counter", + incoming = listOf("Flow_1csfyyz"), + outgoing = listOf("Flow_0zdmt0t"), + boundaryEventRefs = listOf("CompensationEvent_OnSubscriptionCounter"), + ), + FlowNodeDefinition.Event( + id = "StartEvent_SubmitRegistrationForm", + shape = EventShape.START_EVENT, + displayName = "Submit newsletter form", + outgoing = listOf("Flow_1csfyyz"), + eventDefinitions = listOf( + EventDefinitionInstance.Message(MessageReference("Message_FormSubmitted", "Message_FormSubmitted")), + ), + variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), + ), + FlowNodeDefinition.Activity.SubProcess( + id = "SubProcess_Confirmation", + kind = SubProcessKind.PLAIN, + displayName = "Subscription Confirmation", + incoming = listOf("Flow_0zdmt0t"), + outgoing = listOf("Flow_09cuvzp"), + boundaryEventRefs = listOf("ErrorEvent_InvalidMail", "Timer_After3Days"), + flowNodes = confirmationSubProcessNodes(), + sequenceFlows = confirmationSubProcessFlows(), + ), + FlowNodeDefinition.Event( + id = "Timer_After3Days", + shape = EventShape.BOUNDARY_EVENT, + displayName = "After 3 days", + attachedToRef = "SubProcess_Confirmation", + interrupting = true, + outgoing = listOf("Flow_1l1lj4m"), + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DURATION, "$" + "{testVariable}")), + ), +) + +private fun confirmationSubProcessNodes(): List = listOf( + FlowNodeDefinition.Activity.Task( + id = "Activity_ConfirmRegistration", + kind = TaskKind.USER, + displayName = "Confirm subscription", + incoming = listOf("Flow_1bckm43"), + outgoing = listOf("Flow_1cpwe57"), + boundaryEventRefs = listOf("Timer_EveryDay"), + ), + jobWorkerTask( + id = "Activity_SendConfirmationMail", + jobType = "newsletter.sendConfirmationMail", + displayName = "Send confirmation mail", + incoming = listOf("Flow_05i3x1y", "Flow_0x4ewvb"), + outgoing = listOf("Flow_1bckm43"), + variables = listOf(VariableDefinition("subscriptionId", VariableDirection.INPUT)), + ), + FlowNodeDefinition.Event( + id = "EndEvent_SubscriptionConfirmed", + shape = EventShape.END_EVENT, + displayName = "Subscription confirmed", + incoming = listOf("Flow_1cpwe57"), + ), + FlowNodeDefinition.Event( + id = "StartEvent_RequestReceived", + shape = EventShape.START_EVENT, + displayName = "Subscription requested", + outgoing = listOf("Flow_05i3x1y"), + variables = listOf(VariableDefinition("subscriptionId", VariableDirection.OUTPUT)), + ), + FlowNodeDefinition.Event( + id = "Timer_EveryDay", + shape = EventShape.BOUNDARY_EVENT, + displayName = "Every day", + attachedToRef = "Activity_ConfirmRegistration", + interrupting = false, + outgoing = listOf("Flow_0x4ewvb"), + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DURATION, "PT1M")), + ), +) + +private fun confirmationSubProcessFlows(): List = listOf( + SequenceFlowDefinition("Flow_05i3x1y", "StartEvent_RequestReceived", "Activity_SendConfirmationMail"), + SequenceFlowDefinition("Flow_0x4ewvb", "Timer_EveryDay", "Activity_SendConfirmationMail"), + SequenceFlowDefinition("Flow_1bckm43", "Activity_SendConfirmationMail", "Activity_ConfirmRegistration"), + SequenceFlowDefinition("Flow_1cpwe57", "Activity_ConfirmRegistration", "EndEvent_SubscriptionConfirmed"), +) + +fun subscribeNewsletterSequenceFlows(): List = listOf( + SequenceFlowDefinition("Flow_09cuvzp", "SubProcess_Confirmation", "Gateway_SplitNotifications"), + SequenceFlowDefinition("Flow_0i2ctuv", "ErrorEvent_InvalidMail", "EndEvent_RegistrationNotPossible"), + SequenceFlowDefinition("Flow_0zdmt0t", "serviceTask_incrementSubscriptionCounter", "SubProcess_Confirmation"), + SequenceFlowDefinition("Flow_16hub0n", "Gateway_SplitNotifications", "Activity_SendWelcomeMail"), + SequenceFlowDefinition("Flow_1862jd8", "Gateway_JoinNotifications", "EndEvent_RegistrationCompleted"), + SequenceFlowDefinition("Flow_1bsb8no", "CallActivity_AbortRegistration", "CompensationEndEvent_RegistrationAborted"), + SequenceFlowDefinition("Flow_1csfyyz", "StartEvent_SubmitRegistrationForm", "serviceTask_incrementSubscriptionCounter"), + SequenceFlowDefinition("Flow_1duwy83", "Activity_NotifyCommunity", "Gateway_JoinNotifications"), + SequenceFlowDefinition("Flow_1i7hjid", "Activity_SendWelcomeMail", "Gateway_JoinNotifications"), + SequenceFlowDefinition("Flow_1l1lj4m", "Timer_After3Days", "CallActivity_AbortRegistration"), + SequenceFlowDefinition("Flow_1p5t47z", "Gateway_SplitNotifications", "Activity_NotifyCommunity"), +) + +@Suppress("LongParameterList") +fun testSendNewsletterModel( + processId: String = "sendNewsletter", + variantName: String? = null, + flowNodes: List = sendNewsletterFlowNodes(), + sequenceFlows: List = sendNewsletterSequenceFlows(), + messages: List = listOf( + RootElementDefinition.Message("Message_MailRejected", "Message_MailRejected"), + RootElementDefinition.Message("Message_MailRejectedAgain", "Message_MailRejectedAgain"), + ), + signals: List = emptyList(), + errors: List = emptyList(), + escalations: List = listOf( + RootElementDefinition.Escalation("escalation_notifySupport", "escalation_notifySupport", "200"), + ), +) = testProcessModel( + processId = processId, + variantName = variantName, + flowNodes = flowNodes, + sequenceFlows = sequenceFlows, + messages = messages, + signals = signals, + errors = errors, + escalations = escalations, +) + +fun sendNewsletterFlowNodes(): List = listOf( + FlowNodeDefinition.Event( + id = "startEvent_editionCreated", + shape = EventShape.START_EVENT, + outgoing = listOf("Flow_0bianz5"), + ), + jobWorkerTask( + id = "serviceTask_loadSubscribers", + jobType = "newsletter.loadSubscribers", + incoming = listOf("Flow_0bianz5"), + outgoing = listOf("Flow_04andb8"), + variables = listOf( + VariableDefinition("subscribers", VariableDirection.OUTPUT), + VariableDefinition("author", VariableDirection.OUTPUT), + ), + ), + FlowNodeDefinition.Gateway( + id = "gateway_hasSubscribers", + kind = GatewayKind.EXCLUSIVE, + incoming = listOf("Flow_04andb8"), + outgoing = listOf("Flow_1jogut0", "Flow_1gsz7wd"), + defaultFlow = "Flow_1jogut0", + ), + jobWorkerTask( + id = "serviceTask_sendToSubscriber", + jobType = "newsletter.sendMailToSubscriber", + incoming = listOf("Flow_1jogut0"), + outgoing = listOf("Flow_1ruayvl"), + ), + jobWorkerTask( + id = "serviceTask_notifyAuthor", + jobType = "newsletter.notifyAuthor", + incoming = listOf("Flow_1ruayvl"), + outgoing = listOf("Flow_0v2v55n"), + ), + FlowNodeDefinition.Event( + id = "endEvent_editionSent", + shape = EventShape.END_EVENT, + incoming = listOf("Flow_0v2v55n"), + ), + FlowNodeDefinition.Event( + id = "endEvent_noSubscribers", + shape = EventShape.END_EVENT, + incoming = listOf("Flow_1gsz7wd"), + ), + FlowNodeDefinition.Activity.SubProcess( + id = "eventSubProcess_errorHandling", + kind = SubProcessKind.EVENT, + flowNodes = errorHandlingNodes(), + sequenceFlows = errorHandlingFlows(), + ), +) + +private fun errorHandlingNodes(): List = listOf( + FlowNodeDefinition.Event( + id = "event_mailRejected", + shape = EventShape.START_EVENT, + interrupting = true, + outgoing = listOf("Flow_0vtppnk"), + eventDefinitions = listOf( + EventDefinitionInstance.Message(MessageReference("Message_MailRejected", "Message_MailRejected")), + ), + ), + jobWorkerTask( + id = "serviceTask_analyzeError", + jobType = "newsletter.analyzeSendError", + incoming = listOf("Flow_0vtppnk"), + outgoing = listOf("Flow_13nmnag"), + ), + FlowNodeDefinition.Gateway( + id = "gateway_canSendAgain", + kind = GatewayKind.EXCLUSIVE, + incoming = listOf("Flow_13nmnag"), + outgoing = listOf("Flow_1izucof", "Flow_18nf2jh"), + defaultFlow = "Flow_1izucof", + ), + jobWorkerTask( + id = "serviceTask_sendMailAgain", + jobType = "newsletter.sendMailToSubscriber", + incoming = listOf("Flow_1izucof"), + outgoing = listOf("Flow_0vym6nu"), + ), + FlowNodeDefinition.Gateway( + id = "eventGateway_afterSendingAgain", + kind = GatewayKind.EVENT_BASED, + incoming = listOf("Flow_0vym6nu"), + outgoing = listOf("Flow_0enjkoe", "Flow_081cykl"), + ), + FlowNodeDefinition.Event( + id = "timer_noRejectionForOneDay", + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + incoming = listOf("Flow_0enjkoe"), + outgoing = listOf("Flow_0338xzf"), + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DURATION, "PT1D")), + ), + FlowNodeDefinition.Event( + id = "escalationEndEvent_nofitySupport", + shape = EventShape.END_EVENT, + incoming = listOf("Flow_18nf2jh"), + eventDefinitions = listOf( + EventDefinitionInstance.Escalation("escalation_notifySupport", "escalation_notifySupport", "200"), + ), + ), + FlowNodeDefinition.Event( + id = "event_mailRejectedAgain", + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + incoming = listOf("Flow_081cykl"), + outgoing = listOf("Flow_0x9thpq"), + eventDefinitions = listOf( + EventDefinitionInstance.Message(MessageReference("Message_MailRejectedAgain", "Message_MailRejectedAgain")), + ), + ), + FlowNodeDefinition.Event( + id = "escalationEndEvent_nofitySupportAfterRepeatedError", + shape = EventShape.END_EVENT, + incoming = listOf("Flow_0x9thpq"), + ), + FlowNodeDefinition.Event( + id = "endEvent_issueResolved", + shape = EventShape.END_EVENT, + incoming = listOf("Flow_0338xzf"), + ), +) + +private fun errorHandlingFlows(): List = listOf( + SequenceFlowDefinition("Flow_0vtppnk", "event_mailRejected", "serviceTask_analyzeError"), + SequenceFlowDefinition("Flow_13nmnag", "serviceTask_analyzeError", "gateway_canSendAgain"), + SequenceFlowDefinition("Flow_1izucof", "gateway_canSendAgain", "serviceTask_sendMailAgain", flowName = "Yes", isDefault = true), + SequenceFlowDefinition("Flow_18nf2jh", "gateway_canSendAgain", "escalationEndEvent_nofitySupport", flowName = "No", conditionExpression = "\${rejection.reason == \"PERMANENT\"}"), + SequenceFlowDefinition("Flow_0vym6nu", "serviceTask_sendMailAgain", "eventGateway_afterSendingAgain"), + SequenceFlowDefinition("Flow_0enjkoe", "eventGateway_afterSendingAgain", "timer_noRejectionForOneDay"), + SequenceFlowDefinition("Flow_081cykl", "eventGateway_afterSendingAgain", "event_mailRejectedAgain"), + SequenceFlowDefinition("Flow_0x9thpq", "event_mailRejectedAgain", "escalationEndEvent_nofitySupportAfterRepeatedError"), + SequenceFlowDefinition("Flow_0338xzf", "timer_noRejectionForOneDay", "endEvent_issueResolved"), +) + +fun sendNewsletterSequenceFlows(): List = listOf( + SequenceFlowDefinition("Flow_0bianz5", "startEvent_editionCreated", "serviceTask_loadSubscribers"), + SequenceFlowDefinition("Flow_04andb8", "serviceTask_loadSubscribers", "gateway_hasSubscribers"), + SequenceFlowDefinition("Flow_1jogut0", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", flowName = "Yes", isDefault = true), + SequenceFlowDefinition("Flow_1gsz7wd", "gateway_hasSubscribers", "endEvent_noSubscribers", flowName = "No", conditionExpression = "\${subscribers.size() > 0}"), + SequenceFlowDefinition("Flow_1ruayvl", "serviceTask_sendToSubscriber", "serviceTask_notifyAuthor"), + SequenceFlowDefinition("Flow_0v2v55n", "serviceTask_notifyAuthor", "endEvent_editionSent"), +) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/BpmnValidationServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/BpmnValidationServiceTest.kt index a65fd897..6ba9fd2d 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/BpmnValidationServiceTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/BpmnValidationServiceTest.kt @@ -1,10 +1,10 @@ package io.miragon.bpmn.domain.service import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.BpmnValidationException import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.ValidationConfig @@ -18,11 +18,17 @@ class BpmnValidationServiceTest { private val underTest = BpmnValidationService() + private fun serviceTaskWithoutImplementation(id: String) = FlowNodeDefinition.Activity.Task( + id = id, + kind = TaskKind.SERVICE, + implementation = TaskImplementation.Unspecified, + ) + @Test fun `valid model passes all pre-merge rules`() { // given: a valid BPMN model whose detected engine matches the selected one - val model = testBpmnModel(detectedEngine = ProcessEngine.ZEEBE) + val model = testProcessModel(detectedEngine = ProcessEngine.ZEEBE) // when / then: no exception is thrown assertDoesNotThrow { underTest.validate(listOf(model), ProcessEngine.ZEEBE, ValidationPhase.PRE_MERGE) } @@ -32,13 +38,8 @@ class BpmnValidationServiceTest { fun `throws BpmnValidationException for missing service task implementation`() { // given: a model with a service task that has no implementation - val model = testBpmnModel( - flowNodes = listOf( - FlowNodeDefinition( - id = "task1", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition(id = "task1")), - ) - ) + val model = testProcessModel( + flowNodes = listOf(serviceTaskWithoutImplementation("task1")), ) // when: validating pre-merge @@ -57,13 +58,8 @@ class BpmnValidationServiceTest { val underTest = BpmnValidationService( ValidationConfig(disabledRules = setOf("missing-service-task-implementation")) ) - val model = testBpmnModel( - flowNodes = listOf( - FlowNodeDefinition( - id = "task1", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition(id = "task1")), - ) - ) + val model = testProcessModel( + flowNodes = listOf(serviceTaskWithoutImplementation("task1")), ) // when / then: no exception is thrown because the rule is disabled @@ -74,7 +70,7 @@ class BpmnValidationServiceTest { fun `warnings do not throw by default`() { // given: a model that produces only warnings (empty process) - val model = testBpmnModel(flowNodes = emptyList()) + val model = testProcessModel(flowNodes = emptyList()) // when / then: no exception is thrown assertDoesNotThrow { underTest.validate(listOf(model), ProcessEngine.ZEEBE, ValidationPhase.PRE_MERGE) } @@ -85,7 +81,7 @@ class BpmnValidationServiceTest { // given: a service with failOnWarning and a model with an empty process val underTest = BpmnValidationService(ValidationConfig(failOnWarning = true)) - val model = testBpmnModel(flowNodes = emptyList()) + val model = testProcessModel(flowNodes = emptyList()) // when: validating pre-merge val exception = assertThrows { @@ -100,8 +96,8 @@ class BpmnValidationServiceTest { fun `throws BpmnValidationException for flow node with null element id`() { // given: a model containing a flow node without an ID - val model = testBpmnModel( - flowNodes = listOf(FlowNodeDefinition(id = null)) + val model = testProcessModel( + flowNodes = listOf(FlowNodeDefinition.Unknown(id = null)) ) // when: validating pre-merge @@ -119,10 +115,10 @@ class BpmnValidationServiceTest { fun `post-merge collision detection detects collisions`() { // given: a model with two flow nodes that produce the same constant name - val model = testBpmnModel( + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition(id = "endEvent_complete"), - FlowNodeDefinition(id = "endEvent-complete"), + FlowNodeDefinition.Unknown(id = "endEvent_complete"), + FlowNodeDefinition.Unknown(id = "endEvent-complete"), ) ) @@ -140,10 +136,10 @@ class BpmnValidationServiceTest { // given: two flow nodes whose ids keep distinct constants but fold to the same // PascalCase object name — previously emitted non-compiling generated code - val model = testBpmnModel( + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition(id = "foo"), - FlowNodeDefinition(id = "-foo"), + FlowNodeDefinition.Unknown(id = "foo"), + FlowNodeDefinition.Unknown(id = "-foo"), ) ) @@ -163,10 +159,10 @@ class BpmnValidationServiceTest { val underTest = BpmnValidationService( ValidationConfig(disabledRules = setOf("collision-detection")) ) - val model = testBpmnModel( + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition(id = "endEvent_complete"), - FlowNodeDefinition(id = "endEvent-complete"), + FlowNodeDefinition.Unknown(id = "endEvent_complete"), + FlowNodeDefinition.Unknown(id = "endEvent-complete"), ) ) @@ -186,8 +182,8 @@ class BpmnValidationServiceTest { val underTest = BpmnValidationService( ValidationConfig(disabledRules = setOf("missing-element-id")) ) - val model = testBpmnModel( - flowNodes = listOf(FlowNodeDefinition(id = null)) + val model = testProcessModel( + flowNodes = listOf(FlowNodeDefinition.Unknown(id = null)) ) // when: validating pre-merge diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/CollisionDetectionServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/CollisionDetectionServiceTest.kt index e021bd59..0db11232 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/CollisionDetectionServiceTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/CollisionDetectionServiceTest.kt @@ -1,16 +1,14 @@ package io.miragon.bpmn.domain.service -import io.miragon.bpmn.domain.shared.ErrorDefinition +import io.miragon.bpmn.domain.jobWorkerTask +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_VALUE_KEY -import io.miragon.bpmn.domain.shared.SignalDefinition -import io.miragon.bpmn.domain.shared.TimerDefinition +import io.miragon.bpmn.domain.shared.RootElementDefinition +import io.miragon.bpmn.domain.shared.TimerType import io.miragon.bpmn.domain.shared.VariableDefinition import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -22,27 +20,17 @@ class CollisionDetectionServiceTest { fun `findCollisions returns empty when no collisions exist`() { // given: a model with distinct constant names across all element types - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "Activity_Task1"), - FlowNodeDefinition(id = "Activity_Task2"), - FlowNodeDefinition( - id = "Task1", - properties = FlowNodeProperties.ServiceTask( - ServiceTaskDefinition(id = "Task1", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendMail")) - ), - ), - FlowNodeDefinition( - id = "Task2", - properties = FlowNodeProperties.ServiceTask( - ServiceTaskDefinition(id = "Task2", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendConfirmationMail")) - ), - ), + FlowNodeDefinition.Unknown(id = "Activity_Task1"), + FlowNodeDefinition.Unknown(id = "Activity_Task2"), + jobWorkerTask(id = "Task1", jobType = "newsletter.sendMail"), + jobWorkerTask(id = "Task2", jobType = "newsletter.sendConfirmationMail"), ), messages = listOf( - MessageDefinition(id = "Message_FormSubmitted", name = "Message_FormSubmitted"), - MessageDefinition(id = "Message_SubscriptionConfirmed", name = "Message_SubscriptionConfirmed"), + RootElementDefinition.Message(id = "Message_FormSubmitted", name = "Message_FormSubmitted"), + RootElementDefinition.Message(id = "Message_SubscriptionConfirmed", name = "Message_SubscriptionConfirmed"), ), ) @@ -54,15 +42,15 @@ class CollisionDetectionServiceTest { fun `findCollisions allows true duplicates with same original ID`() { // given: a model with exact duplicate elements (same id) - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", messages = listOf( - MessageDefinition(id = "Message_Test", name = "Message_Test"), - MessageDefinition(id = "Message_Test", name = "Message_Test"), + RootElementDefinition.Message(id = "Message_Test", name = "Message_Test"), + RootElementDefinition.Message(id = "Message_Test", name = "Message_Test"), ), flowNodes = listOf( - FlowNodeDefinition(id = "Activity_SendMail"), - FlowNodeDefinition(id = "Activity_SendMail"), + FlowNodeDefinition.Unknown(id = "Activity_SendMail"), + FlowNodeDefinition.Unknown(id = "Activity_SendMail"), ), ) @@ -74,11 +62,11 @@ class CollisionDetectionServiceTest { fun `findCollisions detects collision with case variation in FlowNodes`() { // given: two flow nodes that differ only in case - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "eventData"), - FlowNodeDefinition(id = "EventData"), + FlowNodeDefinition.Unknown(id = "eventData"), + FlowNodeDefinition.Unknown(id = "EventData"), ), ) @@ -97,11 +85,11 @@ class CollisionDetectionServiceTest { fun `findCollisions detects collision with separator variation in FlowNodes`() { // given: two flow nodes that differ only in separator character - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "endEvent_dataProcessed"), - FlowNodeDefinition(id = "endEvent-dataProcessed"), + FlowNodeDefinition.Unknown(id = "endEvent_dataProcessed"), + FlowNodeDefinition.Unknown(id = "endEvent-dataProcessed"), ), ) @@ -122,12 +110,12 @@ class CollisionDetectionServiceTest { fun `findCollisions detects collision with mixed case and separator variation`() { // given: three flow nodes that all normalize to the same constant - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "eventData"), - FlowNodeDefinition(id = "event-data"), - FlowNodeDefinition(id = "event_Data"), + FlowNodeDefinition.Unknown(id = "eventData"), + FlowNodeDefinition.Unknown(id = "event-data"), + FlowNodeDefinition.Unknown(id = "event_Data"), ), ) @@ -150,11 +138,11 @@ class CollisionDetectionServiceTest { // given: two flow nodes whose ids keep distinct constants (FOO, _FOO) but fold to the // same PascalCase object name (Foo) used for Variables/CallActivities objects - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "foo"), - FlowNodeDefinition(id = "-foo"), + FlowNodeDefinition.Unknown(id = "foo"), + FlowNodeDefinition.Unknown(id = "-foo"), ), ) @@ -172,11 +160,11 @@ class CollisionDetectionServiceTest { fun `findCollisions does not double-report a collision that surfaces on both bases`() { // given: two flow nodes that collide in UPPER_SNAKE and in PascalCase folding - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "endEvent_complete"), - FlowNodeDefinition(id = "endEvent-complete"), + FlowNodeDefinition.Unknown(id = "endEvent_complete"), + FlowNodeDefinition.Unknown(id = "endEvent-complete"), ), ) @@ -192,11 +180,11 @@ class CollisionDetectionServiceTest { fun `findCollisions detects UPPER_SNAKE collision that folding misses`() { // given: two flow nodes that share a constant (FOO_BAR) but keep distinct PascalCase names - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "fooBar"), - FlowNodeDefinition(id = "fooBAR"), + FlowNodeDefinition.Unknown(id = "fooBar"), + FlowNodeDefinition.Unknown(id = "fooBAR"), ), ) @@ -213,11 +201,11 @@ class CollisionDetectionServiceTest { fun `findCollisions detects collisions in Messages`() { // given: two messages that normalize to the same constant - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", messages = listOf( - MessageDefinition(id = "msg1", name = "message_formSubmitted"), - MessageDefinition(id = "msg2", name = "message-formSubmitted"), + RootElementDefinition.Message(id = "msg1", name = "message_formSubmitted"), + RootElementDefinition.Message(id = "msg2", name = "message-formSubmitted"), ), ) @@ -234,21 +222,11 @@ class CollisionDetectionServiceTest { fun `findCollisions detects collisions in ServiceTasks`() { // given: two service tasks with implementations that normalize to the same constant - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition( - id = "task1", - properties = FlowNodeProperties.ServiceTask( - ServiceTaskDefinition(id = "task1", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter.sendMail")) - ), - ), - FlowNodeDefinition( - id = "task2", - properties = FlowNodeProperties.ServiceTask( - ServiceTaskDefinition(id = "task2", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "newsletter_sendMail")) - ), - ), + jobWorkerTask(id = "task1", jobType = "newsletter.sendMail"), + jobWorkerTask(id = "task2", jobType = "newsletter_sendMail"), ), ) @@ -265,11 +243,11 @@ class CollisionDetectionServiceTest { fun `findCollisions detects collisions in Signals`() { // given: two signals that normalize to the same constant - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", signals = listOf( - SignalDefinition(id = "sig1", name = "signal.complete"), - SignalDefinition(id = "sig2", name = "signal_complete"), + RootElementDefinition.Signal(id = "sig1", name = "signal.complete"), + RootElementDefinition.Signal(id = "sig2", name = "signal_complete"), ), ) @@ -286,11 +264,11 @@ class CollisionDetectionServiceTest { fun `findCollisions detects collisions in Errors`() { // given: two errors that normalize to the same constant - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", errors = listOf( - ErrorDefinition(id = "err1", name = "Error_InvalidMail", code = "400"), - ErrorDefinition(id = "err2", name = "Error-InvalidMail", code = "400"), + RootElementDefinition.Error(id = "err1", name = "Error_InvalidMail", code = "400"), + RootElementDefinition.Error(id = "err2", name = "Error-InvalidMail", code = "400"), ), ) @@ -306,17 +284,20 @@ class CollisionDetectionServiceTest { @Test fun `findCollisions detects collisions in Timers`() { - // given: two timer flow nodes that normalize to the same constant - val model = testBpmnModel( + // given: two timer event nodes whose ids normalize to the same constant. Timer definitions are + // now keyed by their carrying node's id, so this necessarily surfaces a FlowNode collision too. + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition( - id = "timer1", - properties = FlowNodeProperties.Timer(TimerDefinition(id = "Duration", type = "Duration", value = "PT1M")), + FlowNodeDefinition.Event( + id = "Duration", + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DURATION, "PT1M")), ), - FlowNodeDefinition( - id = "timer2", - properties = FlowNodeProperties.Timer(TimerDefinition(id = "duration", type = "Duration", value = "PT2M")), + FlowNodeDefinition.Event( + id = "duration", + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DURATION, "PT2M")), ), ), ) @@ -324,21 +305,28 @@ class CollisionDetectionServiceTest { // when: checking for collisions val collisions = underTest.findCollisions(model) - // then: one Timer collision is reported - assertThat(collisions).hasSize(1) - assertThat(collisions[0].variableType).isEqualTo("Timer") - assertThat(collisions[0].constantName).isEqualTo("DURATION") + // then: a Timer collision is reported on the shared constant + val timerCollisions = collisions.filter { it.variableType == "Timer" } + assertThat(timerCollisions).hasSize(1) + assertThat(timerCollisions[0].constantName).isEqualTo("DURATION") + assertThat(timerCollisions[0].conflictingIds).containsExactlyInAnyOrder("Duration", "duration") } @Test fun `findCollisions detects collisions in Variables`() { // given: two nodes with variables that normalize to the same constant - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "node1", variables = listOf(VariableDefinition(name = "userId", direction = VariableDirection.INPUT))), - FlowNodeDefinition(id = "node2", variables = listOf(VariableDefinition(name = "user_id", direction = VariableDirection.INPUT))), + FlowNodeDefinition.Unknown( + id = "node1", + variables = listOf(VariableDefinition(name = "userId", direction = VariableDirection.INPUT)), + ), + FlowNodeDefinition.Unknown( + id = "node2", + variables = listOf(VariableDefinition(name = "user_id", direction = VariableDirection.INPUT)), + ), ), ) @@ -355,19 +343,19 @@ class CollisionDetectionServiceTest { fun `findCollisions detects multiple collisions across different variable types`() { // given: a model with collisions in FlowNodes, Messages, and Signals simultaneously - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "endEvent_complete"), - FlowNodeDefinition(id = "endEvent-complete"), + FlowNodeDefinition.Unknown(id = "endEvent_complete"), + FlowNodeDefinition.Unknown(id = "endEvent-complete"), ), messages = listOf( - MessageDefinition(id = "msg1", name = "message_sent"), - MessageDefinition(id = "msg2", name = "message-sent"), + RootElementDefinition.Message(id = "msg1", name = "message_sent"), + RootElementDefinition.Message(id = "msg2", name = "message-sent"), ), signals = listOf( - SignalDefinition(id = "sig1", name = "signal_ready"), - SignalDefinition(id = "sig2", name = "signal-ready"), + RootElementDefinition.Signal(id = "sig1", name = "signal_ready"), + RootElementDefinition.Signal(id = "sig2", name = "signal-ready"), ), ) @@ -387,14 +375,14 @@ class CollisionDetectionServiceTest { fun `findCollisions handles mixed valid and collision cases`() { // given: a model where most nodes are unique but two share a constant name - val model = testBpmnModel( + val model = testProcessModel( processId = "TestProcess", flowNodes = listOf( - FlowNodeDefinition(id = "Activity_Task1"), - FlowNodeDefinition(id = "Activity_Task2"), - FlowNodeDefinition(id = "Activity_Task3"), - FlowNodeDefinition(id = "endEvent_complete"), - FlowNodeDefinition(id = "endEvent-complete"), + FlowNodeDefinition.Unknown(id = "Activity_Task1"), + FlowNodeDefinition.Unknown(id = "Activity_Task2"), + FlowNodeDefinition.Unknown(id = "Activity_Task3"), + FlowNodeDefinition.Unknown(id = "endEvent_complete"), + FlowNodeDefinition.Unknown(id = "endEvent-complete"), ), ) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/ModelMergerServiceTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/ModelMergerServiceTest.kt index 3265e445..c86a5201 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/ModelMergerServiceTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/service/ModelMergerServiceTest.kt @@ -1,20 +1,15 @@ package io.miragon.bpmn.domain.service -import io.miragon.bpmn.domain.BpmnModel -import io.miragon.bpmn.domain.MergedBpmnModel -import io.miragon.bpmn.domain.shared.ErrorDefinition -import io.miragon.bpmn.domain.shared.EscalationDefinition +import io.miragon.bpmn.domain.jobWorkerTask +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.MessageDefinition +import io.miragon.bpmn.domain.shared.RootElementDefinition import io.miragon.bpmn.domain.shared.SequenceFlowDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_VALUE_KEY -import io.miragon.bpmn.domain.shared.SignalDefinition -import io.miragon.bpmn.domain.shared.TimerDefinition +import io.miragon.bpmn.domain.shared.TimerType import io.miragon.bpmn.domain.shared.VariableDefinition import io.miragon.bpmn.domain.shared.VariableDirection -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test @@ -24,49 +19,34 @@ class ModelMergerServiceTest { private val underTest = ModelMergerService() @Test - fun `merges processes with same id into MergedBpmnModel`() { + fun `merges processes with same id into ProcessModel`() { // given: two models with same processId and one with different processId - val firstFlowNode = FlowNodeDefinition( - id = "create-order", - properties = FlowNodeProperties.ServiceTask( - ServiceTaskDefinition(id = "create-order", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "firstTaskType")), - ), - ) - val secondFlowNode = FlowNodeDefinition( - id = "update-order", - properties = FlowNodeProperties.ServiceTask( - ServiceTaskDefinition(id = "update-order", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "secondTaskType")), - ), - ) - val thirdFlowNode = FlowNodeDefinition( - id = "delete-order", - properties = FlowNodeProperties.ServiceTask( - ServiceTaskDefinition(id = "delete-order", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "thirdTaskType")), - ), - ) - val firstMessage = MessageDefinition(id = "firstMessageId", name = "firstMessageName") - val secondMessage = MessageDefinition(id = "secondMessageId", name = "secondMessageName") - val thirdMessage = MessageDefinition(id = "thirdMessageId", name = "thirdMessageName") - val firstEscalation = EscalationDefinition(id = "ESC_1", name = "firstEscalation", code = "100") - val secondEscalation = EscalationDefinition(id = "ESC_2", name = "secondEscalation", code = "200") - val thirdEscalation = EscalationDefinition(id = "ESC_3", name = "thirdEscalation", code = "300") - - val firstModel = testBpmnModel( + val firstFlowNode = jobWorkerTask(id = "create-order", jobType = "firstTaskType") + val secondFlowNode = jobWorkerTask(id = "update-order", jobType = "secondTaskType") + val thirdFlowNode = jobWorkerTask(id = "delete-order", jobType = "thirdTaskType") + val firstMessage = RootElementDefinition.Message(id = "firstMessageId", name = "firstMessageName") + val secondMessage = RootElementDefinition.Message(id = "secondMessageId", name = "secondMessageName") + val thirdMessage = RootElementDefinition.Message(id = "thirdMessageId", name = "thirdMessageName") + val firstEscalation = RootElementDefinition.Escalation(id = "ESC_1", name = "firstEscalation", code = "100") + val secondEscalation = RootElementDefinition.Escalation(id = "ESC_2", name = "secondEscalation", code = "200") + val thirdEscalation = RootElementDefinition.Escalation(id = "ESC_3", name = "thirdEscalation", code = "300") + + val firstModel = testProcessModel( processId = "order-process", variantName = "variantA", flowNodes = listOf(firstFlowNode, secondFlowNode), messages = listOf(firstMessage, secondMessage), escalations = listOf(firstEscalation, secondEscalation), ) - val secondModel = testBpmnModel( + val secondModel = testProcessModel( processId = "order-process", variantName = "variantB", flowNodes = listOf(secondFlowNode, thirdFlowNode), messages = listOf(secondMessage, thirdMessage), escalations = listOf(secondEscalation, thirdEscalation), ) - val otherModel = testBpmnModel( + val otherModel = testProcessModel( processId = "other-order-process", flowNodes = listOf(firstFlowNode, secondFlowNode), messages = listOf(firstMessage, secondMessage), @@ -76,39 +56,45 @@ class ModelMergerServiceTest { // when: merging all models val result = underTest.mergeModels(listOf(firstModel, secondModel, otherModel)) - // then: multi-model group produces MergedBpmnModel with deduplicated shared elements + // then: multi-model group produces ProcessModel with deduplicated shared elements assertThat(result).hasSize(2) val orderProcess = result.first { it.processId == "order-process" } - assertThat(orderProcess).isInstanceOf(MergedBpmnModel::class.java) + assertThat(orderProcess.isMerged).isTrue() assertThat(orderProcess.flowNodes).containsExactly(firstFlowNode, thirdFlowNode, secondFlowNode) - assertThat(orderProcess.messages).containsExactly(firstMessage, secondMessage, thirdMessage) - assertThat(orderProcess.escalations).containsExactly(firstEscalation, secondEscalation, thirdEscalation) - assertThat((orderProcess as MergedBpmnModel).variants).hasSize(2) + assertThat(orderProcess.definitions.messages).containsExactly(firstMessage, secondMessage, thirdMessage) + assertThat(orderProcess.definitions.escalations).containsExactly(firstEscalation, secondEscalation, thirdEscalation) + assertThat(orderProcess.variants).hasSize(2) - // and: single-model group stays as BpmnModel + // and: the single-file process carries no variants val otherProcess = result.first { it.processId == "other-order-process" } - assertThat(otherProcess).isInstanceOf(BpmnModel::class.java) + assertThat(otherProcess.isMerged).isFalse() assertThat(otherProcess.flowNodes).containsExactly(firstFlowNode, secondFlowNode) - assertThat(otherProcess.messages).containsExactly(firstMessage, secondMessage) - assertThat(otherProcess.escalations).containsExactly(firstEscalation) + assertThat(otherProcess.definitions.messages).containsExactly(firstMessage, secondMessage) + assertThat(otherProcess.definitions.escalations).containsExactly(firstEscalation) } @Test fun `sorts all collections alphabetically by raw name`() { // given: model with unsorted elements - val model = testBpmnModel( + val model = testProcessModel( processId = "test-process", flowNodes = listOf( - FlowNodeDefinition(id = "z-node", variables = listOf(VariableDefinition("alphaVar", VariableDirection.INPUT))), - FlowNodeDefinition(id = "a-node", variables = listOf(VariableDefinition("zetaVar", VariableDirection.INPUT))), - FlowNodeDefinition(id = "m-node"), + FlowNodeDefinition.Unknown( + id = "z-node", + variables = listOf(VariableDefinition("alphaVar", VariableDirection.INPUT)), + ), + FlowNodeDefinition.Unknown( + id = "a-node", + variables = listOf(VariableDefinition("zetaVar", VariableDirection.INPUT)), + ), + FlowNodeDefinition.Unknown(id = "m-node"), ), escalations = listOf( - EscalationDefinition(id = "ESC_Z", name = "zEscalation", code = "300"), - EscalationDefinition(id = "ESC_A", name = "aEscalation", code = "100"), - EscalationDefinition(id = "ESC_M", name = "mEscalation", code = "200"), + RootElementDefinition.Escalation(id = "ESC_Z", name = "zEscalation", code = "300"), + RootElementDefinition.Escalation(id = "ESC_A", name = "aEscalation", code = "100"), + RootElementDefinition.Escalation(id = "ESC_M", name = "mEscalation", code = "200"), ), ) @@ -119,40 +105,41 @@ class ModelMergerServiceTest { val sortedModel = result.first() assertThat(sortedModel.flowNodes.map { it.getRawName() }).containsExactly("a-node", "m-node", "z-node") assertThat(sortedModel.variables.map { it.getRawName() }).containsExactly("alphaVar", "zetaVar") - assertThat(sortedModel.escalations.map { it.getRawName() }).containsExactly("aEscalation", "mEscalation", "zEscalation") + assertThat(sortedModel.definitions.escalations.map { it.getRawName() }).containsExactly("aEscalation", "mEscalation", "zEscalation") } @Test fun `deduplicates all elements within single BPMN model`() { // given: a single model with duplicates of various element types - val timerFlowNode = FlowNodeDefinition( + val timerFlowNode = FlowNodeDefinition.Event( id = "TIMER_1", - properties = FlowNodeProperties.Timer(TimerDefinition(id = "TIMER_1", type = "Date", value = "2024-01-01")), + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DATE, "2024-01-01")), ) - val model = testBpmnModel( + val model = testProcessModel( processId = "test-process", errors = listOf( - ErrorDefinition(id = "TEST_ERROR", name = "TEST_ERROR", code = "400"), - ErrorDefinition(id = "TEST_ERROR", name = "TEST_ERROR", code = "400"), + RootElementDefinition.Error(id = "TEST_ERROR", name = "TEST_ERROR", code = "400"), + RootElementDefinition.Error(id = "TEST_ERROR", name = "TEST_ERROR", code = "400"), ), signals = listOf( - SignalDefinition(id = "TEST_SIGNAL", name = "TEST_SIGNAL"), - SignalDefinition(id = "TEST_SIGNAL", name = "TEST_SIGNAL"), + RootElementDefinition.Signal(id = "TEST_SIGNAL", name = "TEST_SIGNAL"), + RootElementDefinition.Signal(id = "TEST_SIGNAL", name = "TEST_SIGNAL"), ), messages = listOf( - MessageDefinition(id = "TEST_MESSAGE", name = "TEST_MESSAGE"), - MessageDefinition(id = "TEST_MESSAGE", name = "TEST_MESSAGE"), + RootElementDefinition.Message(id = "TEST_MESSAGE", name = "TEST_MESSAGE"), + RootElementDefinition.Message(id = "TEST_MESSAGE", name = "TEST_MESSAGE"), ), flowNodes = listOf( - FlowNodeDefinition(id = "node-1"), - FlowNodeDefinition(id = "node-1"), + FlowNodeDefinition.Unknown(id = "node-1"), + FlowNodeDefinition.Unknown(id = "node-1"), timerFlowNode, timerFlowNode, ), escalations = listOf( - EscalationDefinition(id = "TEST_ESC", name = "TEST_ESC", code = "500"), - EscalationDefinition(id = "TEST_ESC", name = "TEST_ESC", code = "500"), + RootElementDefinition.Escalation(id = "TEST_ESC", name = "TEST_ESC", code = "500"), + RootElementDefinition.Escalation(id = "TEST_ESC", name = "TEST_ESC", code = "500"), ), ) @@ -161,109 +148,109 @@ class ModelMergerServiceTest { // then: duplicates should be removed from all element types val merged = result.first() - assertThat(merged.errors).containsExactly(ErrorDefinition(id = "TEST_ERROR", name = "TEST_ERROR", code = "400")) - assertThat(merged.signals).containsExactly(SignalDefinition(id = "TEST_SIGNAL", name = "TEST_SIGNAL")) - assertThat(merged.messages).containsExactly(MessageDefinition(id = "TEST_MESSAGE", name = "TEST_MESSAGE")) - assertThat(merged.flowNodes).containsExactly(timerFlowNode, FlowNodeDefinition(id = "node-1")) - assertThat(merged.escalations).containsExactly(EscalationDefinition(id = "TEST_ESC", name = "TEST_ESC", code = "500")) + assertThat(merged.definitions.errors).containsExactly(RootElementDefinition.Error(id = "TEST_ERROR", name = "TEST_ERROR", code = "400")) + assertThat(merged.definitions.signals).containsExactly(RootElementDefinition.Signal(id = "TEST_SIGNAL", name = "TEST_SIGNAL")) + assertThat(merged.definitions.messages).containsExactly(RootElementDefinition.Message(id = "TEST_MESSAGE", name = "TEST_MESSAGE")) + assertThat(merged.flowNodes).containsExactly(timerFlowNode, FlowNodeDefinition.Unknown(id = "node-1")) + assertThat(merged.definitions.escalations).containsExactly(RootElementDefinition.Escalation(id = "TEST_ESC", name = "TEST_ESC", code = "500")) } @Test fun `deduplicates shared elements across multiple BPMN models with same process ID`() { // given: two models with overlapping elements - val firstModel = testBpmnModel( + val firstModel = testProcessModel( processId = "test-process", variantName = "variantA", errors = listOf( - ErrorDefinition(id = "ERROR_1", name = "ERROR_1", code = "400"), - ErrorDefinition(id = "ERROR_2", name = "ERROR_2", code = "500"), + RootElementDefinition.Error(id = "ERROR_1", name = "ERROR_1", code = "400"), + RootElementDefinition.Error(id = "ERROR_2", name = "ERROR_2", code = "500"), ), signals = listOf( - SignalDefinition(id = "SIGNAL_1", name = "SIGNAL_1"), - SignalDefinition(id = "SIGNAL_2", name = "SIGNAL_2"), + RootElementDefinition.Signal(id = "SIGNAL_1", name = "SIGNAL_1"), + RootElementDefinition.Signal(id = "SIGNAL_2", name = "SIGNAL_2"), ), messages = listOf( - MessageDefinition(id = "MSG_1", name = "MSG_1"), - MessageDefinition(id = "MSG_2", name = "MSG_2"), + RootElementDefinition.Message(id = "MSG_1", name = "MSG_1"), + RootElementDefinition.Message(id = "MSG_2", name = "MSG_2"), ), flowNodes = listOf( - FlowNodeDefinition(id = "node-1"), - FlowNodeDefinition(id = "node-2"), + FlowNodeDefinition.Unknown(id = "node-1"), + FlowNodeDefinition.Unknown(id = "node-2"), ), escalations = listOf( - EscalationDefinition(id = "ESC_1", name = "ESC_1", code = "100"), - EscalationDefinition(id = "ESC_2", name = "ESC_2", code = "200"), + RootElementDefinition.Escalation(id = "ESC_1", name = "ESC_1", code = "100"), + RootElementDefinition.Escalation(id = "ESC_2", name = "ESC_2", code = "200"), ), ) - val secondModel = testBpmnModel( + val secondModel = testProcessModel( processId = "test-process", variantName = "variantB", errors = listOf( - ErrorDefinition(id = "ERROR_2", name = "ERROR_2", code = "500"), - ErrorDefinition(id = "ERROR_3", name = "ERROR_3", code = "600"), + RootElementDefinition.Error(id = "ERROR_2", name = "ERROR_2", code = "500"), + RootElementDefinition.Error(id = "ERROR_3", name = "ERROR_3", code = "600"), ), signals = listOf( - SignalDefinition(id = "SIGNAL_2", name = "SIGNAL_2"), - SignalDefinition(id = "SIGNAL_3", name = "SIGNAL_3"), + RootElementDefinition.Signal(id = "SIGNAL_2", name = "SIGNAL_2"), + RootElementDefinition.Signal(id = "SIGNAL_3", name = "SIGNAL_3"), ), messages = listOf( - MessageDefinition(id = "MSG_2", name = "MSG_2"), - MessageDefinition(id = "MSG_3", name = "MSG_3"), + RootElementDefinition.Message(id = "MSG_2", name = "MSG_2"), + RootElementDefinition.Message(id = "MSG_3", name = "MSG_3"), ), flowNodes = listOf( - FlowNodeDefinition(id = "node-2"), - FlowNodeDefinition(id = "node-3"), + FlowNodeDefinition.Unknown(id = "node-2"), + FlowNodeDefinition.Unknown(id = "node-3"), ), escalations = listOf( - EscalationDefinition(id = "ESC_2", name = "ESC_2", code = "200"), - EscalationDefinition(id = "ESC_3", name = "ESC_3", code = "300"), + RootElementDefinition.Escalation(id = "ESC_2", name = "ESC_2", code = "200"), + RootElementDefinition.Escalation(id = "ESC_3", name = "ESC_3", code = "300"), ), ) // when: merging models val result = underTest.mergeModels(listOf(firstModel, secondModel)) - // then: should produce MergedBpmnModel with deduplicated shared elements + // then: should produce ProcessModel with deduplicated shared elements assertThat(result).hasSize(1) val merged = result.first() - assertThat(merged).isInstanceOf(MergedBpmnModel::class.java) - assertThat(merged.errors.map { it.getRawName() }).containsExactly("ERROR_1", "ERROR_2", "ERROR_3") - assertThat(merged.signals.map { it.getRawName() }).containsExactly("SIGNAL_1", "SIGNAL_2", "SIGNAL_3") - assertThat(merged.messages.map { it.getRawName() }).containsExactly("MSG_1", "MSG_2", "MSG_3") + assertThat(merged.isMerged).isTrue() + assertThat(merged.definitions.errors.map { it.getRawName() }).containsExactly("ERROR_1", "ERROR_2", "ERROR_3") + assertThat(merged.definitions.signals.map { it.getRawName() }).containsExactly("SIGNAL_1", "SIGNAL_2", "SIGNAL_3") + assertThat(merged.definitions.messages.map { it.getRawName() }).containsExactly("MSG_1", "MSG_2", "MSG_3") assertThat(merged.flowNodes.map { it.getRawName() }).containsExactly("node-1", "node-2", "node-3") - assertThat(merged.escalations.map { it.getRawName() }).containsExactly("ESC_1", "ESC_2", "ESC_3") - assertThat((merged as MergedBpmnModel).variants).hasSize(2) + assertThat(merged.definitions.escalations.map { it.getRawName() }).containsExactly("ESC_1", "ESC_2", "ESC_3") + assertThat(merged.variants).hasSize(2) } @Test fun `preserves per-variant sequence flows and flow nodes`() { // given: two models with the same processId but different flows - val sharedNode = FlowNodeDefinition(id = "Gateway_Route") + val sharedNode = FlowNodeDefinition.Unknown(id = "Gateway_Route") val flowDeOnly = SequenceFlowDefinition("Flow_DE", "Gateway_Route", "Task_DE", conditionExpression = "country=DE") val flowAtOnly = SequenceFlowDefinition("Flow_AT", "Gateway_Route", "Task_AT", conditionExpression = "country=AT") - val deModel = testBpmnModel( + val deModel = testProcessModel( processId = "order-process", variantName = "prodDe", - flowNodes = listOf(sharedNode, FlowNodeDefinition(id = "Task_DE", previousElements = listOf("Gateway_Route"))), + flowNodes = listOf(sharedNode, FlowNodeDefinition.Unknown(id = "Task_DE", incoming = listOf("Flow_DE"))), sequenceFlows = listOf(flowDeOnly), ) - val atModel = testBpmnModel( + val atModel = testProcessModel( processId = "order-process", variantName = "prodAt", - flowNodes = listOf(sharedNode, FlowNodeDefinition(id = "Task_AT", previousElements = listOf("Gateway_Route"))), + flowNodes = listOf(sharedNode, FlowNodeDefinition.Unknown(id = "Task_AT", incoming = listOf("Flow_AT"))), sequenceFlows = listOf(flowAtOnly), ) // when: merging models val result = underTest.mergeModels(listOf(deModel, atModel)) - // then: result is a MergedBpmnModel with per-variant data + // then: result is a ProcessModel with per-variant data assertThat(result).hasSize(1) val merged = result.first() - assertThat(merged).isInstanceOf(MergedBpmnModel::class.java) - val mergedModel = merged as MergedBpmnModel + assertThat(merged.isMerged).isTrue() + val mergedModel = merged // and: shared flow nodes are deduplicated assertThat(mergedModel.flowNodes.map { it.getRawName() }).containsExactly("Gateway_Route", "Task_AT", "Task_DE") @@ -277,20 +264,22 @@ class ModelMergerServiceTest { assertThat(atVariant.sequenceFlows).containsExactly(flowAtOnly) assertThat(atVariant.flowNodes.map { it.getRawName() }).containsExactly("Gateway_Route", "Task_AT") - // and: top-level sequenceFlows is empty on MergedBpmnModel - assertThat(mergedModel.sequenceFlows).isEmpty() + // and: top-level sequenceFlows holds the union across variants, so a consumer ignoring variants + // still sees the complete process (ADR 018 — was previously returned empty) + assertThat(mergedModel.sequenceFlows).containsExactlyInAnyOrder(flowDeOnly, flowAtOnly) } @Test fun `unions additionalInputVariables across variants on a shared flow node`() { // given: two variants both define the same message start event with different additional input variables - val variantA = testBpmnModel( + val variantA = testProcessModel( processId = "order-process", variantName = "variantA", flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "MessageStart_1", + shape = EventShape.START_EVENT, variables = listOf( VariableDefinition("varA1", VariableDirection.INPUT), VariableDefinition("shared", VariableDirection.INPUT), @@ -298,12 +287,13 @@ class ModelMergerServiceTest { ), ), ) - val variantB = testBpmnModel( + val variantB = testProcessModel( processId = "order-process", variantName = "variantB", flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "MessageStart_1", + shape = EventShape.START_EVENT, variables = listOf( VariableDefinition("varB1", VariableDirection.INPUT), VariableDefinition("shared", VariableDirection.INPUT), @@ -316,7 +306,7 @@ class ModelMergerServiceTest { val result = underTest.mergeModels(listOf(variantA, variantB)) // then: the merged top-level flow node carries the union of all variants' variables, deduplicated - val merged = result.first() as MergedBpmnModel + val merged = result.first() val mergedNode = merged.flowNodes.first { it.getRawName() == "MessageStart_1" } assertThat(mergedNode.variables).containsExactlyInAnyOrder( VariableDefinition("varA1", VariableDirection.INPUT), @@ -337,17 +327,17 @@ class ModelMergerServiceTest { fun `preserves variables on a flow node that exists only in one variant`() { // given: a node that exists only in variantB - val variantA = testBpmnModel( + val variantA = testProcessModel( processId = "order-process", variantName = "variantA", - flowNodes = listOf(FlowNodeDefinition(id = "Task_Shared")), + flowNodes = listOf(FlowNodeDefinition.Unknown(id = "Task_Shared")), ) - val variantB = testBpmnModel( + val variantB = testProcessModel( processId = "order-process", variantName = "variantB", flowNodes = listOf( - FlowNodeDefinition(id = "Task_Shared"), - FlowNodeDefinition( + FlowNodeDefinition.Unknown(id = "Task_Shared"), + FlowNodeDefinition.Unknown( id = "Task_OnlyInB", variables = listOf(VariableDefinition("onlyInB", VariableDirection.OUTPUT)), ), @@ -358,7 +348,7 @@ class ModelMergerServiceTest { val result = underTest.mergeModels(listOf(variantA, variantB)) // then: variant-only node and its variables surface in the merged top-level flow nodes - val merged = result.first() as MergedBpmnModel + val merged = result.first() val onlyInB = merged.flowNodes.first { it.getRawName() == "Task_OnlyInB" } assertThat(onlyInB.variables).containsExactly(VariableDefinition("onlyInB", VariableDirection.OUTPUT)) } @@ -367,18 +357,18 @@ class ModelMergerServiceTest { fun `orders variants and base node selection deterministically regardless of input order`() { // given: three variants of one process, each providing a different name for the shared node - fun variant(name: String) = testBpmnModel( + fun variant(name: String) = testProcessModel( processId = "order-process", variantName = name, - flowNodes = listOf(FlowNodeDefinition(id = "Task_Shared", displayName = "name-from-$name")), + flowNodes = listOf(FlowNodeDefinition.Unknown(id = "Task_Shared", displayName = "name-from-$name")), ) val dev = variant("dev") val prod = variant("prod") val staging = variant("staging") // when: merging the same set in two different input orders - val forward = underTest.mergeModels(listOf(dev, prod, staging)).first() as MergedBpmnModel - val shuffled = underTest.mergeModels(listOf(staging, dev, prod)).first() as MergedBpmnModel + val forward = underTest.mergeModels(listOf(dev, prod, staging)).first() + val shuffled = underTest.mergeModels(listOf(staging, dev, prod)).first() // then: variants are emitted sorted by variantName, independent of input order assertThat(forward.variants.map { it.variantName }).containsExactly("dev", "prod", "staging") @@ -390,22 +380,22 @@ class ModelMergerServiceTest { } @Test - fun `returns single model as BpmnModel`() { + fun `returns a single-file process without variants`() { // given: a single model val flow = SequenceFlowDefinition("Flow_1", "Start", "End") - val model = testBpmnModel( + val model = testProcessModel( processId = "simple-process", - flowNodes = listOf(FlowNodeDefinition(id = "Start"), FlowNodeDefinition(id = "End")), + flowNodes = listOf(FlowNodeDefinition.Unknown(id = "Start"), FlowNodeDefinition.Unknown(id = "End")), sequenceFlows = listOf(flow), ) // when: merging a single model val result = underTest.mergeModels(listOf(model)) - // then: single model is returned as BpmnModel, not wrapped + // then: no variant wrapping happens for a single file assertThat(result).hasSize(1) - assertThat(result.first()).isInstanceOf(BpmnModel::class.java) + assertThat(result.first().isMerged).isFalse() assertThat(result.first().sequenceFlows).containsExactly(flow) } @@ -413,8 +403,8 @@ class ModelMergerServiceTest { fun `throws when multiple models share processId without variantName`() { // given: two models with same processId but no variantName - val model1 = testBpmnModel(processId = "order-process") - val model2 = testBpmnModel(processId = "order-process") + val model1 = testProcessModel(processId = "order-process") + val model2 = testProcessModel(processId = "order-process") // when / then assertThatThrownBy { underTest.mergeModels(listOf(model1, model2)) } @@ -423,12 +413,80 @@ class ModelMergerServiceTest { .hasMessageContaining("variantName") } + @Test + fun `keeps root elements that share a name but have their own id`() { + + // given: a model whose modeller created two bpmn:Message elements with the same name — the common + // result of typing the same name on two events instead of picking the existing message + val model = testProcessModel( + processId = "order-process", + messages = listOf( + RootElementDefinition.Message(id = "Message_1", name = "OrderPlaced"), + RootElementDefinition.Message(id = "Message_2", name = "OrderPlaced"), + ), + signals = listOf( + RootElementDefinition.Signal(id = "Signal_1", name = "OrderCancelled"), + RootElementDefinition.Signal(id = "Signal_2", name = "OrderCancelled"), + ), + ) + + // when + val merged = underTest.mergeModels(listOf(model)).single() + + // then: both survive, so every messageRef emitted by the extractor still resolves in the registry + assertThat(merged.definitions.messages.map { it.id }).containsExactlyInAnyOrder("Message_1", "Message_2") + assertThat(merged.definitions.signals.map { it.id }).containsExactlyInAnyOrder("Signal_1", "Signal_2") + } + + @Test + fun `reports a merged process as non-executable when no variant is executable`() { + + // given: two variants of one process, both marked isExecutable="false" + val first = testProcessModel(processId = "order-process", variantName = "de").copy(isExecutable = false) + val second = testProcessModel(processId = "order-process", variantName = "en").copy(isExecutable = false) + + // when + val merged = underTest.mergeModels(listOf(first, second)).single() + + // then: the merged model must not claim to be executable — the JSON publishes this flag + assertThat(merged.isExecutable).isFalse() + } + + @Test + fun `reports a merged process as executable when at least one variant is`() { + + // given + val first = testProcessModel(processId = "order-process", variantName = "de").copy(isExecutable = false) + val second = testProcessModel(processId = "order-process", variantName = "en") + + // when + val merged = underTest.mergeModels(listOf(first, second)).single() + + // then + assertThat(merged.isExecutable).isTrue() + } + + @Test + fun `deduplicates root elements that repeat across variants`() { + + // given: two variants that both reference the same root elements + val shared = RootElementDefinition.Message(id = "Message_1", name = "OrderPlaced") + val first = testProcessModel(processId = "order-process", variantName = "de", messages = listOf(shared)) + val second = testProcessModel(processId = "order-process", variantName = "en", messages = listOf(shared)) + + // when + val merged = underTest.mergeModels(listOf(first, second)).single() + + // then: the same id appears once, not once per variant + assertThat(merged.definitions.messages.map { it.id }).containsExactly("Message_1") + } + @Test fun `throws when some models have variantName and some do not`() { // given: mixed variantName presence - val model1 = testBpmnModel(processId = "order-process", variantName = "prodDe") - val model2 = testBpmnModel(processId = "order-process") + val model1 = testProcessModel(processId = "order-process", variantName = "prodDe") + val model2 = testProcessModel(processId = "order-process") // when / then assertThatThrownBy { underTest.mergeModels(listOf(model1, model2)) } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/CallActivityTargetExistsRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/CallActivityTargetExistsRuleTest.kt index d3aeb175..5626850d 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/CallActivityTargetExistsRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/CallActivityTargetExistsRuleTest.kt @@ -2,9 +2,8 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.CallActivityDefinition import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.CrossModelValidationContext import io.miragon.bpmn.domain.validation.model.Severity import org.assertj.core.api.Assertions.assertThat @@ -14,12 +13,12 @@ class CallActivityTargetExistsRuleTest { private val underTest = CallActivityTargetExistsRule() - private fun caller(processId: String, callId: String, calledElement: String?) = testBpmnModel( + private fun caller(processId: String, callId: String, calledElement: String?) = testProcessModel( processId = processId, flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Activity.CallActivity( id = callId, - properties = FlowNodeProperties.CallActivity(CallActivityDefinition(id = callId, calledElement = calledElement)), + definition = CallActivityDefinition(id = callId, calledElement = calledElement), ), ), ) @@ -45,7 +44,7 @@ class CallActivityTargetExistsRuleTest { // given: both the caller and the called process are loaded val caller = caller(processId = "orderFulfillment", callId = "call1", calledElement = "paymentProcessing") - val called = testBpmnModel(processId = "paymentProcessing") + val called = testProcessModel(processId = "paymentProcessing") // when val violations = underTest.validate(CrossModelValidationContext(models = listOf(caller, called), engine = ProcessEngine.CAMUNDA_7)) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/CollisionDetectionRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/CollisionDetectionRuleTest.kt index 3aa0521b..55938a1b 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/CollisionDetectionRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/CollisionDetectionRuleTest.kt @@ -2,7 +2,7 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.FlowNodeDefinition import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import io.miragon.bpmn.domain.validation.model.ValidationPhase @@ -22,10 +22,10 @@ class CollisionDetectionRuleTest { fun `reports collision when different IDs normalize to same constant`() { // given: two flow nodes that differ only in separator - val model = testBpmnModel( + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition(id = "endEvent_complete"), - FlowNodeDefinition(id = "endEvent-complete"), + FlowNodeDefinition.Unknown(id = "endEvent_complete"), + FlowNodeDefinition.Unknown(id = "endEvent-complete"), ) ) @@ -43,10 +43,10 @@ class CollisionDetectionRuleTest { // given: two flow nodes whose ids keep distinct constants but fold to the same // PascalCase object name (previously emitted two non-compiling `object Foo`) - val model = testBpmnModel( + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition(id = "foo"), - FlowNodeDefinition(id = "-foo"), + FlowNodeDefinition.Unknown(id = "foo"), + FlowNodeDefinition.Unknown(id = "-foo"), ) ) @@ -63,10 +63,10 @@ class CollisionDetectionRuleTest { fun `no violations when no collisions`() { // given: two flow nodes with distinct constant names - val model = testBpmnModel( + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition(id = "Activity_One"), - FlowNodeDefinition(id = "Activity_Two"), + FlowNodeDefinition.Unknown(id = "Activity_One"), + FlowNodeDefinition.Unknown(id = "Activity_Two"), ) ) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/EmptyProcessRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/EmptyProcessRuleTest.kt index 2220f787..64eb0f44 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/EmptyProcessRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/EmptyProcessRuleTest.kt @@ -2,7 +2,7 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.FlowNodeDefinition import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -16,7 +16,7 @@ class EmptyProcessRuleTest { fun `reports warning for process with no elements`() { // given: a model with no flow nodes - val model = testBpmnModel(flowNodes = emptyList()) + val model = testProcessModel(flowNodes = emptyList()) // when / then: a WARN violation is reported val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) @@ -28,8 +28,8 @@ class EmptyProcessRuleTest { fun `no violations for process with elements`() { // given: a model with at least one flow node - val model = testBpmnModel( - flowNodes = listOf(FlowNodeDefinition(id = "Activity_Task1")) + val model = testProcessModel( + flowNodes = listOf(FlowNodeDefinition.Unknown(id = "Activity_Task1")) ) // when / then: no violations diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/EngineMismatchRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/EngineMismatchRuleTest.kt index 7f39e32e..19397313 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/EngineMismatchRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/EngineMismatchRuleTest.kt @@ -1,7 +1,7 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -15,7 +15,7 @@ class EngineMismatchRuleTest { fun `reports an error when the model targets a different engine`() { // given: a model detected as Camunda 7 but validated for Operaton (the reported case) - val model = testBpmnModel(detectedEngine = ProcessEngine.CAMUNDA_7) + val model = testProcessModel(detectedEngine = ProcessEngine.CAMUNDA_7) // when / then: a single engine-mismatch ERROR is produced val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.OPERATON)) @@ -29,7 +29,7 @@ class EngineMismatchRuleTest { fun `no violation when the detected engine matches the selected engine`() { // given: a model whose detected engine matches the selected one - val model = testBpmnModel(detectedEngine = ProcessEngine.ZEEBE) + val model = testProcessModel(detectedEngine = ProcessEngine.ZEEBE) // when / then: no violation is reported assertThat(underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE))).isEmpty() @@ -39,7 +39,7 @@ class EngineMismatchRuleTest { fun `warns when the source engine could not be detected`() { // given: a model whose target engine could not be determined - val model = testBpmnModel(detectedEngine = null) + val model = testProcessModel(detectedEngine = null) // when / then: a single engine-mismatch WARN is produced val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.CAMUNDA_7)) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingCalledElementRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingCalledElementRuleTest.kt index 4a2e208b..284f0d68 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingCalledElementRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingCalledElementRuleTest.kt @@ -2,9 +2,8 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.CallActivityDefinition import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -18,32 +17,33 @@ class MissingCalledElementRuleTest { fun `reports error for call activity with null calledElement`() { // given: a call activity with no calledElement set - val model = testBpmnModel( + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Activity.CallActivity( id = "call1", - properties = FlowNodeProperties.CallActivity(CallActivityDefinition(id = "call1", calledElement = null)), - ) - ) + definition = CallActivityDefinition(id = "call1", calledElement = null), + ), + ), ) // when / then: an ERROR violation is reported val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) assertThat(violations).hasSize(1) assertThat(violations[0].severity).isEqualTo(Severity.ERROR) + assertThat(violations[0].elementId).isEqualTo("call1") } @Test fun `no violations for call activity with calledElement`() { // given: a call activity with a valid calledElement reference - val model = testBpmnModel( + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Activity.CallActivity( id = "call1", - properties = FlowNodeProperties.CallActivity(CallActivityDefinition(id = "call1", calledElement = "my-sub-process")), - ) - ) + definition = CallActivityDefinition(id = "call1", calledElement = "my-sub-process"), + ), + ), ) // when / then: no violations diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingElementIdRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingElementIdRuleTest.kt index 901fe4bf..4086428e 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingElementIdRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingElementIdRuleTest.kt @@ -2,7 +2,7 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.FlowNodeDefinition import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -16,8 +16,8 @@ class MissingElementIdRuleTest { fun `reports error for flow node with null id`() { // given: a model containing a flow node without an ID - val model = testBpmnModel( - flowNodes = listOf(FlowNodeDefinition(id = null)) + val model = testProcessModel( + flowNodes = listOf(FlowNodeDefinition.Unknown(id = null)) ) // when / then: an ERROR violation mentioning "FlowNode has no ID" @@ -31,8 +31,8 @@ class MissingElementIdRuleTest { fun `no violations for elements with valid ids`() { // given: a flow node with a valid ID - val model = testBpmnModel( - flowNodes = listOf(FlowNodeDefinition(id = "Activity_SendMail")) + val model = testProcessModel( + flowNodes = listOf(FlowNodeDefinition.Unknown(id = "Activity_SendMail")) ) // when / then: no violations diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingErrorDefinitionRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingErrorDefinitionRuleTest.kt index 336256bc..dd1b5115 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingErrorDefinitionRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingErrorDefinitionRuleTest.kt @@ -1,8 +1,10 @@ package io.miragon.bpmn.domain.validation.rules -import io.miragon.bpmn.domain.shared.ErrorDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -12,43 +14,57 @@ class MissingErrorDefinitionRuleTest { private val underTest = MissingErrorDefinitionRule() + private fun errorEvent(id: String, errorRef: String?, errorName: String?, errorCode: String?) = + FlowNodeDefinition.Event( + id = id, + shape = EventShape.END_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Error(errorRef, errorName, errorCode)), + ) + + private fun validate(node: FlowNodeDefinition) = + underTest.validate(SingleModelValidationContext(model = testProcessModel(flowNodes = listOf(node)), engine = ProcessEngine.ZEEBE)) + @Test - fun `reports error for error with null name`() { + fun `reports error for an error event whose definition has no name`() { - // given: an error element with no name - val model = testBpmnModel( - errors = listOf(ErrorDefinition(id = "err1", name = null, code = "500")) - ) + // given: an error event referencing an error root element but carrying no name + val node = errorEvent(id = "errorEnd1", errorRef = "Error_1", errorName = null, errorCode = "500") - // when / then: an ERROR violation is reported - val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) + // when / then: an ERROR violation is reported for the event node + val violations = validate(node) assertThat(violations).hasSize(1) assertThat(violations[0].severity).isEqualTo(Severity.ERROR) + assertThat(violations[0].elementId).isEqualTo("errorEnd1") } @Test - fun `reports error for error with null code`() { + fun `reports error for an error event whose definition has no code`() { - // given: an error element with no code - val model = testBpmnModel( - errors = listOf(ErrorDefinition(id = "err1", name = "MyError", code = null)) - ) + // given: an error event referencing an error root element but carrying no code + val node = errorEvent(id = "errorEnd1", errorRef = "Error_1", errorName = "MyError", errorCode = null) // when / then: a violation is reported - val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) + val violations = validate(node) assertThat(violations).hasSize(1) } @Test - fun `no violations for error with all fields`() { + fun `no violations for an error event with all fields`() { - // given: a fully defined error element - val model = testBpmnModel( - errors = listOf(ErrorDefinition(id = "err1", name = "MyError", code = "500")) - ) + // given: a fully defined error event + val node = errorEvent(id = "errorEnd1", errorRef = "Error_1", errorName = "MyError", errorCode = "500") // when / then: no violations - val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) - assertThat(violations).isEmpty() + assertThat(validate(node)).isEmpty() + } + + @Test + fun `does not flag a catch-all error event without an errorRef`() { + + // given: an error boundary event that catches any error (no errorRef, no name, no code) + val node = errorEvent(id = "catchAll", errorRef = null, errorName = null, errorCode = null) + + // when / then: not flagged - a missing definition is only a problem when an error is actually referenced + assertThat(validate(node)).isEmpty() } } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingMessageNameRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingMessageNameRuleTest.kt index b3fa8e3b..85bf5878 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingMessageNameRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingMessageNameRuleTest.kt @@ -1,8 +1,12 @@ package io.miragon.bpmn.domain.validation.rules -import io.miragon.bpmn.domain.shared.MessageDefinition +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.MessageReference import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -12,31 +16,77 @@ class MissingMessageNameRuleTest { private val underTest = MissingMessageNameRule() + private fun validate(node: FlowNodeDefinition) = + underTest.validate(SingleModelValidationContext(model = testProcessModel(flowNodes = listOf(node)), engine = ProcessEngine.ZEEBE)) + @Test - fun `reports error for message with null name`() { + fun `reports error for a message event whose message has no name`() { - // given: a message element with no name - val model = testBpmnModel( - messages = listOf(MessageDefinition(id = "msg1", name = null)) + // given: a message catch event whose message reference carries no name + val node = FlowNodeDefinition.Event( + id = "msgEvent1", + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Message(MessageReference(messageRef = "msg1", messageName = null))), ) - // when / then: an ERROR violation is reported for the message element - val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) + // when / then: an ERROR violation is reported for the event node + val violations = validate(node) assertThat(violations).hasSize(1) assertThat(violations[0].severity).isEqualTo(Severity.ERROR) - assertThat(violations[0].elementId).isEqualTo("msg1") + assertThat(violations[0].elementId).isEqualTo("msgEvent1") } @Test - fun `no violations for message with valid name`() { + fun `no violations for a message event with a valid name`() { - // given: a message element with a valid name - val model = testBpmnModel( - messages = listOf(MessageDefinition(id = "msg1", name = "MyMessage")) + // given: a message catch event whose message reference has a name + val node = FlowNodeDefinition.Event( + id = "msgEvent1", + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Message(MessageReference(messageRef = "msg1", messageName = "MyMessage"))), ) // when / then: no violations - val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) - assertThat(violations).isEmpty() + assertThat(validate(node)).isEmpty() + } + + @Test + fun `does not flag a message throw event that carries no message reference`() { + + // given: a Zeebe message end event that publishes via a job worker, with an empty message reference + val node = FlowNodeDefinition.Event( + id = "msgThrow1", + shape = EventShape.END_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Message(MessageReference())), + ) + + // when / then: not flagged - there is no referenced message to be missing a name + assertThat(validate(node)).isEmpty() + } + + @Test + fun `reports error for a send task whose message has no name`() { + + // given: a send task that references a message with no name + val node = FlowNodeDefinition.Activity.Task( + id = "send1", + kind = TaskKind.SEND, + message = MessageReference(messageRef = "msg1", messageName = null), + ) + + // when / then: an ERROR violation is reported for the task + val violations = validate(node) + assertThat(violations).hasSize(1) + assertThat(violations[0].elementId).isEqualTo("send1") + } + + @Test + fun `does not flag a send task with no message`() { + + // given: a send task that carries no message at all - not this rule's concern + val node = FlowNodeDefinition.Activity.Task(id = "send1", kind = TaskKind.SEND, message = null) + + // when / then: not flagged + assertThat(validate(node)).isEmpty() } } diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingProcessIdRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingProcessIdRuleTest.kt index cc5d33b2..4e06f7e6 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingProcessIdRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingProcessIdRuleTest.kt @@ -1,7 +1,7 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -15,7 +15,7 @@ class MissingProcessIdRuleTest { fun `reports error for blank process id`() { // given: a model with an empty process ID - val model = testBpmnModel(processId = "") + val model = testProcessModel(processId = "") // when / then: an ERROR violation is reported val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) @@ -27,7 +27,7 @@ class MissingProcessIdRuleTest { fun `no violations for valid process id`() { // given: a model with a non-blank process ID - val model = testBpmnModel(processId = "my-process") + val model = testProcessModel(processId = "my-process") // when / then: no violations val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingServiceTaskImplementationRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingServiceTaskImplementationRuleTest.kt index 589b5df9..7b099792 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingServiceTaskImplementationRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingServiceTaskImplementationRuleTest.kt @@ -1,11 +1,10 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition -import io.miragon.bpmn.domain.shared.ServiceTaskDefinition.Companion.IMPL_VALUE_KEY -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.shared.TaskImplementation +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -15,18 +14,20 @@ class MissingServiceTaskImplementationRuleTest { private val underTest = MissingServiceTaskImplementationRule() + /** + * A service task that is declared service-task-like but carries no implementation configuration. + */ + private fun unimplementedServiceTask(id: String) = FlowNodeDefinition.Activity.Task( + id = id, + kind = TaskKind.SERVICE, + implementation = TaskImplementation.Unspecified, + ) + @Test - fun `reports error for service task with null type`() { + fun `reports error for service task with no implementation`() { // given: a service task with no implementation - val model = testBpmnModel( - flowNodes = listOf( - FlowNodeDefinition( - id = "task1", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition(id = "task1")), - ) - ) - ) + val model = testProcessModel(flowNodes = listOf(unimplementedServiceTask("task1"))) // when: validating against Zeebe val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) @@ -39,20 +40,38 @@ class MissingServiceTaskImplementationRuleTest { } @Test - fun `no violations for service task with valid type`() { + fun `reports every unimplemented service task, not just the first`() { - // given: a service task with a valid implementation - val model = testBpmnModel( + // given: three service tasks that all lack an implementation + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition( - id = "task1", - properties = FlowNodeProperties.ServiceTask( - ServiceTaskDefinition(id = "task1", engineSpecificProperties = mapOf(IMPL_VALUE_KEY to "myWorker")) - ), - ) + unimplementedServiceTask("task1"), + unimplementedServiceTask("task2"), + unimplementedServiceTask("task3"), ) ) + // when + val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) + + // then: each one is named — they are distinct elements even though they share an empty implementation + assertThat(violations.map { it.elementId }).containsExactlyInAnyOrder("task1", "task2", "task3") + } + + @Test + fun `no violations for service task with valid implementation`() { + + // given: a service task with a resolved job-worker implementation + val model = testProcessModel( + flowNodes = listOf( + FlowNodeDefinition.Activity.Task( + id = "task1", + kind = TaskKind.SERVICE, + implementation = TaskImplementation.JobWorker("myWorker"), + ), + ), + ) + // when / then: no violations (for any engine) val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.CAMUNDA_7)) assertThat(violations).isEmpty() @@ -62,14 +81,7 @@ class MissingServiceTaskImplementationRuleTest { fun `engine-specific hint for Camunda 7`() { // given: a service task with no implementation validated against Camunda 7 - val model = testBpmnModel( - flowNodes = listOf( - FlowNodeDefinition( - id = "task1", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition(id = "task1")), - ) - ) - ) + val model = testProcessModel(flowNodes = listOf(unimplementedServiceTask("task1"))) // when / then: the violation message mentions camunda:topic val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.CAMUNDA_7)) @@ -80,14 +92,7 @@ class MissingServiceTaskImplementationRuleTest { fun `engine-specific hint for Operaton`() { // given: a service task with no implementation validated against Operaton - val model = testBpmnModel( - flowNodes = listOf( - FlowNodeDefinition( - id = "task1", - properties = FlowNodeProperties.ServiceTask(ServiceTaskDefinition(id = "task1")), - ) - ) - ) + val model = testProcessModel(flowNodes = listOf(unimplementedServiceTask("task1"))) // when / then: the violation message mentions operaton:topic val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.OPERATON)) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingSignalNameRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingSignalNameRuleTest.kt index 433970f3..60880e0e 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingSignalNameRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingSignalNameRuleTest.kt @@ -1,8 +1,8 @@ package io.miragon.bpmn.domain.validation.rules import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.SignalDefinition -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.shared.RootElementDefinition +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -16,8 +16,8 @@ class MissingSignalNameRuleTest { fun `reports error for signal with null name`() { // given: a signal element with no name - val model = testBpmnModel( - signals = listOf(SignalDefinition(id = "sig1", name = null)) + val model = testProcessModel( + signals = listOf(RootElementDefinition.Signal(id = "sig1", name = null)) ) // when / then: an ERROR violation is reported @@ -30,8 +30,8 @@ class MissingSignalNameRuleTest { fun `no violations for signal with name`() { // given: a signal element with a valid name - val model = testBpmnModel( - signals = listOf(SignalDefinition(id = "sig1", name = "MySignal")) + val model = testProcessModel( + signals = listOf(RootElementDefinition.Signal(id = "sig1", name = "MySignal")) ) // when / then: no violations diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingTimerDefinitionRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingTimerDefinitionRuleTest.kt index 3012f151..4add6001 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingTimerDefinitionRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/MissingTimerDefinitionRuleTest.kt @@ -1,10 +1,11 @@ package io.miragon.bpmn.domain.validation.rules +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.shared.TimerType +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import org.assertj.core.api.Assertions.assertThat @@ -15,35 +16,38 @@ class MissingTimerDefinitionRuleTest { private val underTest = MissingTimerDefinitionRule() @Test - fun `reports error for timer with null type`() { + fun `reports error for timer with no type`() { - // given: a timer flow node with no type or value - val model = testBpmnModel( + // given: a timer event carrying a definition with neither a type nor an expression + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "timer1", - properties = FlowNodeProperties.Timer(TimerDefinition(id = "timer1", type = null, value = null)), - ) - ) + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Timer(timerType = null, expression = null)), + ), + ), ) // when / then: an ERROR violation is reported val violations = underTest.validate(SingleModelValidationContext(model = model, engine = ProcessEngine.ZEEBE)) assertThat(violations).hasSize(1) assertThat(violations[0].severity).isEqualTo(Severity.ERROR) + assertThat(violations[0].elementId).isEqualTo("timer1") } @Test - fun `no violations for timer with type and value`() { + fun `no violations for timer with type and expression`() { - // given: a timer flow node with a valid type and value - val model = testBpmnModel( + // given: a timer event with a valid type and expression + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Event( id = "timer1", - properties = FlowNodeProperties.Timer(TimerDefinition(id = "timer1", type = "Duration", value = "PT1H")), - ) - ) + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Timer(TimerType.DURATION, "PT1H")), + ), + ), ) // when / then: no violations diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/TimerCronSyntaxRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/TimerCronSyntaxRuleTest.kt index 0f6ad814..71d9b99a 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/TimerCronSyntaxRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/TimerCronSyntaxRuleTest.kt @@ -1,10 +1,11 @@ package io.miragon.bpmn.domain.validation.rules +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.shared.TimerType +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import io.miragon.bpmn.domain.validation.model.ValidationViolation @@ -23,12 +24,12 @@ class TimerCronSyntaxRuleTest { @Test fun `no violation for a valid cron cycle`() { - assertThat(validate("Timer_1", "Cycle", "0 0 9 ? * MON-FRI")).isEmpty() + assertThat(validate("Timer_1", TimerType.CYCLE, "0 0 9 ? * MON-FRI")).isEmpty() } @Test fun `reports an error for an invalid cron cycle`() { - val violations = validate("Timer_Bad", "Cycle", "not a cron") + val violations = validate("Timer_Bad", TimerType.CYCLE, "not a cron") assertThat(violations).hasSize(1) assertThat(violations.single().elementId).isEqualTo("Timer_Bad") assertThat(violations.single().severity).isEqualTo(Severity.ERROR) @@ -36,28 +37,29 @@ class TimerCronSyntaxRuleTest { @Test fun `reports an error for a cron cycle with the wrong field count`() { - assertThat(validate("Timer_Bad", "Cycle", "0 0 9 * *")).hasSize(1) + assertThat(validate("Timer_Bad", TimerType.CYCLE, "0 0 9 * *")).hasSize(1) } @Test fun `ignores non-cycle timers`() { - assertThat(validate("Timer_D", "Duration", "PT15M")).isEmpty() - assertThat(validate("Timer_Dt", "Date", "2026-01-01T00:00:00Z")).isEmpty() + assertThat(validate("Timer_D", TimerType.DURATION, "PT15M")).isEmpty() + assertThat(validate("Timer_Dt", TimerType.DATE, "2026-01-01T00:00:00Z")).isEmpty() } @Test fun `skips expression and blank values`() { - assertThat(validate("Timer_Feel", "Cycle", "=cronVar")).isEmpty() - assertThat(validate("Timer_El", "Cycle", "\${cronVar}")).isEmpty() - assertThat(validate("Timer_Blank", "Cycle", "")).isEmpty() + assertThat(validate("Timer_Feel", TimerType.CYCLE, "=cronVar")).isEmpty() + assertThat(validate("Timer_El", TimerType.CYCLE, "\${cronVar}")).isEmpty() + assertThat(validate("Timer_Blank", TimerType.CYCLE, "")).isEmpty() } - private fun validate(id: String, type: String?, value: String?): List { - val model = testBpmnModel( + private fun validate(id: String, type: TimerType?, value: String?): List { + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Event( id = id, - properties = FlowNodeProperties.Timer(TimerDefinition(id = id, type = type, value = value)), + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Timer(type, value)), ), ), ) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/TimerIso8601SyntaxRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/TimerIso8601SyntaxRuleTest.kt index 76152ae5..bf94dd65 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/TimerIso8601SyntaxRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/TimerIso8601SyntaxRuleTest.kt @@ -1,10 +1,11 @@ package io.miragon.bpmn.domain.validation.rules +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.shared.TimerDefinition -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.shared.TimerType +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import io.miragon.bpmn.domain.validation.model.ValidationViolation @@ -23,15 +24,15 @@ class TimerIso8601SyntaxRuleTest { @Test fun `no violation for valid iso values per type`() { - assertThat(validate("Timer_Date", "Date", "2026-01-01T00:00:00Z")).isEmpty() - assertThat(validate("Timer_Dur", "Duration", "PT15M")).isEmpty() - assertThat(validate("Timer_Dur2", "Duration", "P1Y2M")).isEmpty() - assertThat(validate("Timer_Cyc", "Cycle", "R3/PT10M")).isEmpty() + assertThat(validate("Timer_Date", TimerType.DATE, "2026-01-01T00:00:00Z")).isEmpty() + assertThat(validate("Timer_Dur", TimerType.DURATION, "PT15M")).isEmpty() + assertThat(validate("Timer_Dur2", TimerType.DURATION, "P1Y2M")).isEmpty() + assertThat(validate("Timer_Cyc", TimerType.CYCLE, "R3/PT10M")).isEmpty() } @Test fun `reports an error for an invalid iso duration`() { - val violations = validate("Timer_Bad", "Duration", "15 minutes") + val violations = validate("Timer_Bad", TimerType.DURATION, "15 minutes") assertThat(violations).hasSize(1) assertThat(violations.single().elementId).isEqualTo("Timer_Bad") assertThat(violations.single().severity).isEqualTo(Severity.ERROR) @@ -39,19 +40,19 @@ class TimerIso8601SyntaxRuleTest { @Test fun `reports an error for an invalid iso date`() { - assertThat(validate("Timer_Bad", "Date", "01/01/2026")).hasSize(1) + assertThat(validate("Timer_Bad", TimerType.DATE, "01/01/2026")).hasSize(1) } @Test fun `reports an error for a cron cycle under the iso rule`() { - assertThat(validate("Timer_Bad", "Cycle", "0 0 9 * * ?")).hasSize(1) + assertThat(validate("Timer_Bad", TimerType.CYCLE, "0 0 9 * * ?")).hasSize(1) } @Test fun `skips expression and blank values`() { - assertThat(validate("Timer_Feel", "Duration", "=durationVar")).isEmpty() - assertThat(validate("Timer_El", "Duration", "\${durationVar}")).isEmpty() - assertThat(validate("Timer_Blank", "Duration", "")).isEmpty() + assertThat(validate("Timer_Feel", TimerType.DURATION, "=durationVar")).isEmpty() + assertThat(validate("Timer_El", TimerType.DURATION, "\${durationVar}")).isEmpty() + assertThat(validate("Timer_Blank", TimerType.DURATION, "")).isEmpty() } @Test @@ -59,12 +60,13 @@ class TimerIso8601SyntaxRuleTest { assertThat(validate("Timer_NoType", null, "whatever")).isEmpty() } - private fun validate(id: String, type: String?, value: String?): List { - val model = testBpmnModel( + private fun validate(id: String, type: TimerType?, value: String?): List { + val model = testProcessModel( flowNodes = listOf( - FlowNodeDefinition( + FlowNodeDefinition.Event( id = id, - properties = FlowNodeProperties.Timer(TimerDefinition(id = id, type = type, value = value)), + shape = EventShape.INTERMEDIATE_CATCH_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Timer(type, value)), ), ), ) diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtMessageThrowRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtMessageThrowRuleTest.kt index f05f1c8f..04d55287 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtMessageThrowRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtMessageThrowRuleTest.kt @@ -1,10 +1,12 @@ package io.miragon.bpmn.domain.validation.rules +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties -import io.miragon.bpmn.domain.shared.EventDirection +import io.miragon.bpmn.domain.shared.MessageReference import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.CrossModelValidationContext import io.miragon.bpmn.domain.validation.model.Severity import org.assertj.core.api.Assertions.assertThat @@ -14,20 +16,20 @@ class UncaughtMessageThrowRuleTest { private val underTest = UncaughtMessageThrowRule() - private fun throwNode(id: String, message: String) = FlowNodeDefinition( + private fun messageEvent(id: String, message: String, shape: EventShape) = FlowNodeDefinition.Event( id = id, - properties = FlowNodeProperties.MessageEvent(message, EventDirection.THROW), + shape = shape, + eventDefinitions = listOf(EventDefinitionInstance.Message(MessageReference(messageName = message))), ) - private fun catchNode(id: String, message: String) = FlowNodeDefinition( - id = id, - properties = FlowNodeProperties.MessageEvent(message, EventDirection.CATCH), - ) + private fun throwNode(id: String, message: String) = messageEvent(id, message, EventShape.INTERMEDIATE_THROW_EVENT) + + private fun catchNode(id: String, message: String) = messageEvent(id, message, EventShape.INTERMEDIATE_CATCH_EVENT) private fun model(processId: String, vararg nodes: FlowNodeDefinition) = - testBpmnModel(processId = processId, flowNodes = nodes.toList()) + testProcessModel(processId = processId, flowNodes = nodes.toList()) - private fun validate(vararg models: io.miragon.bpmn.domain.BpmnModel) = + private fun validate(vararg models: ProcessModel) = underTest.validate(CrossModelValidationContext(models = models.toList(), engine = ProcessEngine.ZEEBE)) @Test diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtSignalThrowRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtSignalThrowRuleTest.kt index 4c95673c..e217d9fb 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtSignalThrowRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UncaughtSignalThrowRuleTest.kt @@ -1,10 +1,11 @@ package io.miragon.bpmn.domain.validation.rules -import io.miragon.bpmn.domain.shared.EventDirection +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.CrossModelValidationContext import io.miragon.bpmn.domain.validation.model.Severity import org.assertj.core.api.Assertions.assertThat @@ -14,20 +15,20 @@ class UncaughtSignalThrowRuleTest { private val underTest = UncaughtSignalThrowRule() - private fun throwNode(id: String, signal: String) = FlowNodeDefinition( + private fun signalEvent(id: String, signal: String, shape: EventShape) = FlowNodeDefinition.Event( id = id, - properties = FlowNodeProperties.SignalEvent(signal, EventDirection.THROW), + shape = shape, + eventDefinitions = listOf(EventDefinitionInstance.Signal(signalName = signal)), ) - private fun catchNode(id: String, signal: String) = FlowNodeDefinition( - id = id, - properties = FlowNodeProperties.SignalEvent(signal, EventDirection.CATCH), - ) + private fun throwNode(id: String, signal: String) = signalEvent(id, signal, EventShape.INTERMEDIATE_THROW_EVENT) + + private fun catchNode(id: String, signal: String) = signalEvent(id, signal, EventShape.INTERMEDIATE_CATCH_EVENT) private fun model(processId: String, vararg nodes: FlowNodeDefinition) = - testBpmnModel(processId = processId, flowNodes = nodes.toList()) + testProcessModel(processId = processId, flowNodes = nodes.toList()) - private fun validate(vararg models: io.miragon.bpmn.domain.BpmnModel) = + private fun validate(vararg models: ProcessModel) = underTest.validate(CrossModelValidationContext(models = models.toList(), engine = ProcessEngine.ZEEBE)) @Test diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UnpublishedSignalCatchRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UnpublishedSignalCatchRuleTest.kt index be0a8ecc..bd64c7b9 100644 --- a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UnpublishedSignalCatchRuleTest.kt +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UnpublishedSignalCatchRuleTest.kt @@ -1,10 +1,11 @@ package io.miragon.bpmn.domain.validation.rules -import io.miragon.bpmn.domain.shared.EventDirection +import io.miragon.bpmn.domain.ProcessModel +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape import io.miragon.bpmn.domain.shared.FlowNodeDefinition -import io.miragon.bpmn.domain.shared.FlowNodeProperties import io.miragon.bpmn.domain.shared.ProcessEngine -import io.miragon.bpmn.domain.testBpmnModel +import io.miragon.bpmn.domain.testProcessModel import io.miragon.bpmn.domain.validation.model.CrossModelValidationContext import io.miragon.bpmn.domain.validation.model.Severity import org.assertj.core.api.Assertions.assertThat @@ -14,20 +15,20 @@ class UnpublishedSignalCatchRuleTest { private val underTest = UnpublishedSignalCatchRule() - private fun throwNode(id: String, signal: String) = FlowNodeDefinition( + private fun signalEvent(id: String, signal: String, shape: EventShape) = FlowNodeDefinition.Event( id = id, - properties = FlowNodeProperties.SignalEvent(signal, EventDirection.THROW), + shape = shape, + eventDefinitions = listOf(EventDefinitionInstance.Signal(signalName = signal)), ) - private fun catchNode(id: String, signal: String) = FlowNodeDefinition( - id = id, - properties = FlowNodeProperties.SignalEvent(signal, EventDirection.CATCH), - ) + private fun throwNode(id: String, signal: String) = signalEvent(id, signal, EventShape.INTERMEDIATE_THROW_EVENT) + + private fun catchNode(id: String, signal: String) = signalEvent(id, signal, EventShape.INTERMEDIATE_CATCH_EVENT) private fun model(processId: String, vararg nodes: FlowNodeDefinition) = - testBpmnModel(processId = processId, flowNodes = nodes.toList()) + testProcessModel(processId = processId, flowNodes = nodes.toList()) - private fun validate(vararg models: io.miragon.bpmn.domain.BpmnModel) = + private fun validate(vararg models: ProcessModel) = underTest.validate(CrossModelValidationContext(models = models.toList(), engine = ProcessEngine.ZEEBE)) @Test diff --git a/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UnreferencedRootElementRuleTest.kt b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UnreferencedRootElementRuleTest.kt new file mode 100644 index 00000000..3f7c8e55 --- /dev/null +++ b/bpmn-to-code-core/src/test/kotlin/io/miragon/bpmn/domain/validation/rules/UnreferencedRootElementRuleTest.kt @@ -0,0 +1,103 @@ +package io.miragon.bpmn.domain.validation.rules + +import io.miragon.bpmn.domain.shared.EventDefinitionInstance +import io.miragon.bpmn.domain.shared.EventShape +import io.miragon.bpmn.domain.shared.FlowNodeDefinition +import io.miragon.bpmn.domain.shared.MessageReference +import io.miragon.bpmn.domain.shared.ProcessEngine +import io.miragon.bpmn.domain.shared.RootElementDefinition +import io.miragon.bpmn.domain.shared.TaskKind +import io.miragon.bpmn.domain.testProcessModel +import io.miragon.bpmn.domain.validation.model.Severity +import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class UnreferencedRootElementRuleTest { + + private val underTest = UnreferencedRootElementRule() + + private fun messageStartEvent(messageRef: String) = FlowNodeDefinition.Event( + id = "StartEvent_Received", + shape = EventShape.START_EVENT, + eventDefinitions = listOf(EventDefinitionInstance.Message(MessageReference(messageRef, "used"))), + ) + + @Test + fun `warns about a message no element references`() { + + // given: two declared messages, only one of which an event points at + val model = testProcessModel( + flowNodes = listOf(messageStartEvent("Message_Used")), + messages = listOf( + RootElementDefinition.Message(id = "Message_Used", name = "used"), + RootElementDefinition.Message(id = "Message_Orphan", name = "orphan"), + ), + signals = emptyList(), + errors = emptyList(), + ) + + // when + val violations = underTest.validate(SingleModelValidationContext(model, ProcessEngine.ZEEBE)) + + // then: the leftover declaration is named, the used one is not + assertThat(violations).hasSize(1) + assertThat(violations.single().elementId).isEqualTo("Message_Orphan") + assertThat(violations.single().severity).isEqualTo(Severity.WARN) + assertThat(violations.single().message).contains("no element references it") + } + + @Test + fun `reports nothing when every root element is referenced`() { + + // given + val model = testProcessModel( + flowNodes = listOf(messageStartEvent("Message_Used")), + messages = listOf(RootElementDefinition.Message(id = "Message_Used", name = "used")), + signals = emptyList(), + errors = emptyList(), + ) + + // when / then + assertThat(underTest.validate(SingleModelValidationContext(model, ProcessEngine.ZEEBE))).isEmpty() + } + + @Test + fun `covers signals and errors as well as messages`() { + + // given: an unreferenced entry in each registry + val model = testProcessModel( + flowNodes = listOf(FlowNodeDefinition.Unknown(id = "node")), + messages = listOf(RootElementDefinition.Message(id = "Message_Orphan", name = "m")), + signals = listOf(RootElementDefinition.Signal(id = "Signal_Orphan", name = "s")), + errors = listOf(RootElementDefinition.Error(id = "Error_Orphan", name = "e", code = "500")), + ) + + // when + val violations = underTest.validate(SingleModelValidationContext(model, ProcessEngine.ZEEBE)) + + // then + assertThat(violations.map { it.elementId }) + .containsExactlyInAnyOrder("Message_Orphan", "Signal_Orphan", "Error_Orphan") + } + + @Test + fun `counts a message referenced by a receive task as used`() { + + // given: send and receive tasks reference their message directly, not through an event definition + val receiveTask = FlowNodeDefinition.Activity.Task( + id = "Activity_Await", + kind = TaskKind.RECEIVE, + message = MessageReference("Message_Used", "used"), + ) + val model = testProcessModel( + flowNodes = listOf(receiveTask), + messages = listOf(RootElementDefinition.Message(id = "Message_Used", name = "used")), + signals = emptyList(), + errors = emptyList(), + ) + + // when / then + assertThat(underTest.validate(SingleModelValidationContext(model, ProcessEngine.ZEEBE))).isEmpty() + } +} diff --git a/bpmn-to-code-core/src/test/resources/api/MultiVariantProcessApiJava.txt b/bpmn-to-code-core/src/test/resources/api/MultiVariantProcessApiJava.txt index 94470bdc..1f403449 100644 --- a/bpmn-to-code-core/src/test/resources/api/MultiVariantProcessApiJava.txt +++ b/bpmn-to-code-core/src/test/resources/api/MultiVariantProcessApiJava.txt @@ -24,41 +24,41 @@ public final class SendNewsletterProcessApi { * Worker runtime code rarely needs these. */ public static final class Elements { - public static final ElementId START_EVENT_EDITION_CREATED = new ElementId("startEvent_editionCreated"); - - public static final ElementId SERVICE_TASK_LOAD_SUBSCRIBERS = new ElementId("serviceTask_loadSubscribers"); + public static final ElementId END_EVENT_EDITION_SENT = new ElementId("endEvent_editionSent"); - public static final ElementId GATEWAY_HAS_SUBSCRIBERS = new ElementId("gateway_hasSubscribers"); + public static final ElementId END_EVENT_ISSUE_RESOLVED = new ElementId("endEvent_issueResolved"); - public static final ElementId SERVICE_TASK_SEND_TO_SUBSCRIBER = new ElementId("serviceTask_sendToSubscriber"); + public static final ElementId END_EVENT_NO_SUBSCRIBERS = new ElementId("endEvent_noSubscribers"); - public static final ElementId SERVICE_TASK_NOTIFY_AUTHOR = new ElementId("serviceTask_notifyAuthor"); + public static final ElementId ESCALATION_END_EVENT_NOFITY_SUPPORT = new ElementId("escalationEndEvent_nofitySupport"); - public static final ElementId END_EVENT_EDITION_SENT = new ElementId("endEvent_editionSent"); + public static final ElementId ESCALATION_END_EVENT_NOFITY_SUPPORT_AFTER_REPEATED_ERROR = new ElementId("escalationEndEvent_nofitySupportAfterRepeatedError"); - public static final ElementId END_EVENT_NO_SUBSCRIBERS = new ElementId("endEvent_noSubscribers"); + public static final ElementId EVENT_GATEWAY_AFTER_SENDING_AGAIN = new ElementId("eventGateway_afterSendingAgain"); public static final ElementId EVENT_SUB_PROCESS_ERROR_HANDLING = new ElementId("eventSubProcess_errorHandling"); public static final ElementId EVENT_MAIL_REJECTED = new ElementId("event_mailRejected"); - public static final ElementId SERVICE_TASK_ANALYZE_ERROR = new ElementId("serviceTask_analyzeError"); + public static final ElementId EVENT_MAIL_REJECTED_AGAIN = new ElementId("event_mailRejectedAgain"); public static final ElementId GATEWAY_CAN_SEND_AGAIN = new ElementId("gateway_canSendAgain"); - public static final ElementId SERVICE_TASK_SEND_MAIL_AGAIN = new ElementId("serviceTask_sendMailAgain"); + public static final ElementId GATEWAY_HAS_SUBSCRIBERS = new ElementId("gateway_hasSubscribers"); - public static final ElementId EVENT_GATEWAY_AFTER_SENDING_AGAIN = new ElementId("eventGateway_afterSendingAgain"); + public static final ElementId SERVICE_TASK_ANALYZE_ERROR = new ElementId("serviceTask_analyzeError"); - public static final ElementId TIMER_NO_REJECTION_FOR_ONE_DAY = new ElementId("timer_noRejectionForOneDay"); + public static final ElementId SERVICE_TASK_LOAD_SUBSCRIBERS = new ElementId("serviceTask_loadSubscribers"); - public static final ElementId ESCALATION_END_EVENT_NOFITY_SUPPORT = new ElementId("escalationEndEvent_nofitySupport"); + public static final ElementId SERVICE_TASK_NOTIFY_AUTHOR = new ElementId("serviceTask_notifyAuthor"); - public static final ElementId EVENT_MAIL_REJECTED_AGAIN = new ElementId("event_mailRejectedAgain"); + public static final ElementId SERVICE_TASK_SEND_MAIL_AGAIN = new ElementId("serviceTask_sendMailAgain"); - public static final ElementId ESCALATION_END_EVENT_NOFITY_SUPPORT_AFTER_REPEATED_ERROR = new ElementId("escalationEndEvent_nofitySupportAfterRepeatedError"); + public static final ElementId SERVICE_TASK_SEND_TO_SUBSCRIBER = new ElementId("serviceTask_sendToSubscriber"); - public static final ElementId END_EVENT_ISSUE_RESOLVED = new ElementId("endEvent_issueResolved"); + public static final ElementId START_EVENT_EDITION_CREATED = new ElementId("startEvent_editionCreated"); + + public static final ElementId TIMER_NO_REJECTION_FOR_ONE_DAY = new ElementId("timer_noRejectionForOneDay"); } /** @@ -113,35 +113,35 @@ public final class SendNewsletterProcessApi { * Worker code typically does not need these. */ public static final class Flows { - public static final BpmnFlow FLOW_0_BIANZ_5 = new BpmnFlow("Flow_0bianz5", null, "startEvent_editionCreated", "serviceTask_loadSubscribers", null, false); + public static final BpmnFlow FLOW_0338_XZF = new BpmnFlow("Flow_0338xzf", null, "timer_noRejectionForOneDay", "endEvent_issueResolved", null, false); public static final BpmnFlow FLOW_04_ANDB_8 = new BpmnFlow("Flow_04andb8", null, "serviceTask_loadSubscribers", "gateway_hasSubscribers", null, false); - public static final BpmnFlow FLOW_1_JOGUT_0 = new BpmnFlow("Flow_1jogut0", "Yes", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", null, true); + public static final BpmnFlow FLOW_081_CYKL = new BpmnFlow("Flow_081cykl", null, "eventGateway_afterSendingAgain", "event_mailRejectedAgain", null, false); - public static final BpmnFlow FLOW_1_GSZ_7_WD = new BpmnFlow("Flow_1gsz7wd", "No", "gateway_hasSubscribers", "endEvent_noSubscribers", "${subscribers.size() > 0}", false); + public static final BpmnFlow FLOW_0_BIANZ_5 = new BpmnFlow("Flow_0bianz5", null, "startEvent_editionCreated", "serviceTask_loadSubscribers", null, false); - public static final BpmnFlow FLOW_1_RUAYVL = new BpmnFlow("Flow_1ruayvl", null, "serviceTask_sendToSubscriber", "serviceTask_notifyAuthor", null, false); + public static final BpmnFlow FLOW_0_ENJKOE = new BpmnFlow("Flow_0enjkoe", null, "eventGateway_afterSendingAgain", "timer_noRejectionForOneDay", null, false); public static final BpmnFlow FLOW_0_V_2_V_55_N = new BpmnFlow("Flow_0v2v55n", null, "serviceTask_notifyAuthor", "endEvent_editionSent", null, false); public static final BpmnFlow FLOW_0_VTPPNK = new BpmnFlow("Flow_0vtppnk", null, "event_mailRejected", "serviceTask_analyzeError", null, false); - public static final BpmnFlow FLOW_13_NMNAG = new BpmnFlow("Flow_13nmnag", null, "serviceTask_analyzeError", "gateway_canSendAgain", null, false); + public static final BpmnFlow FLOW_0_VYM_6_NU = new BpmnFlow("Flow_0vym6nu", null, "serviceTask_sendMailAgain", "eventGateway_afterSendingAgain", null, false); - public static final BpmnFlow FLOW_1_IZUCOF = new BpmnFlow("Flow_1izucof", "Yes", "gateway_canSendAgain", "serviceTask_sendMailAgain", null, true); + public static final BpmnFlow FLOW_0_X_9_THPQ = new BpmnFlow("Flow_0x9thpq", null, "event_mailRejectedAgain", "escalationEndEvent_nofitySupportAfterRepeatedError", null, false); - public static final BpmnFlow FLOW_18_NF_2_JH = new BpmnFlow("Flow_18nf2jh", "No", "gateway_canSendAgain", "escalationEndEvent_nofitySupport", "${rejection.reason == \"PERMANENT\"}", false); + public static final BpmnFlow FLOW_13_NMNAG = new BpmnFlow("Flow_13nmnag", null, "serviceTask_analyzeError", "gateway_canSendAgain", null, false); - public static final BpmnFlow FLOW_0_VYM_6_NU = new BpmnFlow("Flow_0vym6nu", null, "serviceTask_sendMailAgain", "eventGateway_afterSendingAgain", null, false); + public static final BpmnFlow FLOW_18_NF_2_JH = new BpmnFlow("Flow_18nf2jh", "No", "gateway_canSendAgain", "escalationEndEvent_nofitySupport", "${rejection.reason == \"PERMANENT\"}", false); - public static final BpmnFlow FLOW_0_ENJKOE = new BpmnFlow("Flow_0enjkoe", null, "eventGateway_afterSendingAgain", "timer_noRejectionForOneDay", null, false); + public static final BpmnFlow FLOW_1_GSZ_7_WD = new BpmnFlow("Flow_1gsz7wd", "No", "gateway_hasSubscribers", "endEvent_noSubscribers", "${subscribers.size() > 0}", false); - public static final BpmnFlow FLOW_081_CYKL = new BpmnFlow("Flow_081cykl", null, "eventGateway_afterSendingAgain", "event_mailRejectedAgain", null, false); + public static final BpmnFlow FLOW_1_IZUCOF = new BpmnFlow("Flow_1izucof", "Yes", "gateway_canSendAgain", "serviceTask_sendMailAgain", null, true); - public static final BpmnFlow FLOW_0_X_9_THPQ = new BpmnFlow("Flow_0x9thpq", null, "event_mailRejectedAgain", "escalationEndEvent_nofitySupportAfterRepeatedError", null, false); + public static final BpmnFlow FLOW_1_JOGUT_0 = new BpmnFlow("Flow_1jogut0", "Yes", "gateway_hasSubscribers", "serviceTask_sendToSubscriber", null, true); - public static final BpmnFlow FLOW_0338_XZF = new BpmnFlow("Flow_0338xzf", null, "timer_noRejectionForOneDay", "endEvent_issueResolved", null, false); + public static final BpmnFlow FLOW_1_RUAYVL = new BpmnFlow("Flow_1ruayvl", null, "serviceTask_sendToSubscriber", "serviceTask_notifyAuthor", null, false); } /** @@ -161,7 +161,7 @@ public final class SendNewsletterProcessApi { public static final BpmnRelations EVENT_GATEWAY_AFTER_SENDING_AGAIN = new BpmnRelations(null, List.of("serviceTask_sendMailAgain"), List.of("timer_noRejectionForOneDay", "event_mailRejectedAgain"), "eventSubProcess_errorHandling", null, List.of(), "EVENT_BASED_GATEWAY"); - public static final BpmnRelations EVENT_SUB_PROCESS_ERROR_HANDLING = new BpmnRelations(null, List.of(), List.of(), null, null, List.of(), "SUB_PROCESS"); + public static final BpmnRelations EVENT_SUB_PROCESS_ERROR_HANDLING = new BpmnRelations(null, List.of(), List.of(), null, null, List.of(), "EVENT_SUB_PROCESS"); public static final BpmnRelations EVENT_MAIL_REJECTED = new BpmnRelations(null, List.of(), List.of("serviceTask_analyzeError"), "eventSubProcess_errorHandling", null, List.of(), "MESSAGE_START_EVENT"); diff --git a/bpmn-to-code-core/src/test/resources/api/MultiVariantProcessApiKotlin.txt b/bpmn-to-code-core/src/test/resources/api/MultiVariantProcessApiKotlin.txt index a2b2b7b9..73bdb63d 100644 --- a/bpmn-to-code-core/src/test/resources/api/MultiVariantProcessApiKotlin.txt +++ b/bpmn-to-code-core/src/test/resources/api/MultiVariantProcessApiKotlin.txt @@ -26,46 +26,46 @@ object SendNewsletterProcessApi { * Worker runtime code rarely needs these. */ object Elements { - val START_EVENT_EDITION_CREATED: ElementId = ElementId("startEvent_editionCreated") - - val SERVICE_TASK_LOAD_SUBSCRIBERS: ElementId = ElementId("serviceTask_loadSubscribers") + val END_EVENT_EDITION_SENT: ElementId = ElementId("endEvent_editionSent") - val GATEWAY_HAS_SUBSCRIBERS: ElementId = ElementId("gateway_hasSubscribers") + val END_EVENT_ISSUE_RESOLVED: ElementId = ElementId("endEvent_issueResolved") - val SERVICE_TASK_SEND_TO_SUBSCRIBER: ElementId = - ElementId("serviceTask_sendToSubscriber") + val END_EVENT_NO_SUBSCRIBERS: ElementId = ElementId("endEvent_noSubscribers") - val SERVICE_TASK_NOTIFY_AUTHOR: ElementId = ElementId("serviceTask_notifyAuthor") + val ESCALATION_END_EVENT_NOFITY_SUPPORT: ElementId = + ElementId("escalationEndEvent_nofitySupport") - val END_EVENT_EDITION_SENT: ElementId = ElementId("endEvent_editionSent") + val ESCALATION_END_EVENT_NOFITY_SUPPORT_AFTER_REPEATED_ERROR: ElementId = + ElementId("escalationEndEvent_nofitySupportAfterRepeatedError") - val END_EVENT_NO_SUBSCRIBERS: ElementId = ElementId("endEvent_noSubscribers") + val EVENT_GATEWAY_AFTER_SENDING_AGAIN: ElementId = + ElementId("eventGateway_afterSendingAgain") val EVENT_SUB_PROCESS_ERROR_HANDLING: ElementId = ElementId("eventSubProcess_errorHandling") val EVENT_MAIL_REJECTED: ElementId = ElementId("event_mailRejected") - val SERVICE_TASK_ANALYZE_ERROR: ElementId = ElementId("serviceTask_analyzeError") + val EVENT_MAIL_REJECTED_AGAIN: ElementId = ElementId("event_mailRejectedAgain") val GATEWAY_CAN_SEND_AGAIN: ElementId = ElementId("gateway_canSendAgain") - val SERVICE_TASK_SEND_MAIL_AGAIN: ElementId = ElementId("serviceTask_sendMailAgain") + val GATEWAY_HAS_SUBSCRIBERS: ElementId = ElementId("gateway_hasSubscribers") - val EVENT_GATEWAY_AFTER_SENDING_AGAIN: ElementId = - ElementId("eventGateway_afterSendingAgain") + val SERVICE_TASK_ANALYZE_ERROR: ElementId = ElementId("serviceTask_analyzeError") - val TIMER_NO_REJECTION_FOR_ONE_DAY: ElementId = ElementId("timer_noRejectionForOneDay") + val SERVICE_TASK_LOAD_SUBSCRIBERS: ElementId = ElementId("serviceTask_loadSubscribers") - val ESCALATION_END_EVENT_NOFITY_SUPPORT: ElementId = - ElementId("escalationEndEvent_nofitySupport") + val SERVICE_TASK_NOTIFY_AUTHOR: ElementId = ElementId("serviceTask_notifyAuthor") - val EVENT_MAIL_REJECTED_AGAIN: ElementId = ElementId("event_mailRejectedAgain") + val SERVICE_TASK_SEND_MAIL_AGAIN: ElementId = ElementId("serviceTask_sendMailAgain") - val ESCALATION_END_EVENT_NOFITY_SUPPORT_AFTER_REPEATED_ERROR: ElementId = - ElementId("escalationEndEvent_nofitySupportAfterRepeatedError") + val SERVICE_TASK_SEND_TO_SUBSCRIBER: ElementId = + ElementId("serviceTask_sendToSubscriber") - val END_EVENT_ISSUE_RESOLVED: ElementId = ElementId("endEvent_issueResolved") + val START_EVENT_EDITION_CREATED: ElementId = ElementId("startEvent_editionCreated") + + val TIMER_NO_REJECTION_FOR_ONE_DAY: ElementId = ElementId("timer_noRejectionForOneDay") } /** @@ -121,10 +121,10 @@ object SendNewsletterProcessApi { * Worker code typically does not need these. */ object Flows { - val FLOW_0_BIANZ_5: BpmnFlow = BpmnFlow( - id = "Flow_0bianz5", - sourceRef = "startEvent_editionCreated", - targetRef = "serviceTask_loadSubscribers", + val FLOW_0338_XZF: BpmnFlow = BpmnFlow( + id = "Flow_0338xzf", + sourceRef = "timer_noRejectionForOneDay", + targetRef = "endEvent_issueResolved", ) val FLOW_04_ANDB_8: BpmnFlow = BpmnFlow( @@ -133,26 +133,22 @@ object SendNewsletterProcessApi { targetRef = "gateway_hasSubscribers", ) - val FLOW_1_JOGUT_0: BpmnFlow = BpmnFlow( - id = "Flow_1jogut0", - name = "Yes", - sourceRef = "gateway_hasSubscribers", - targetRef = "serviceTask_sendToSubscriber", - isDefault = true, + val FLOW_081_CYKL: BpmnFlow = BpmnFlow( + id = "Flow_081cykl", + sourceRef = "eventGateway_afterSendingAgain", + targetRef = "event_mailRejectedAgain", ) - val FLOW_1_GSZ_7_WD: BpmnFlow = BpmnFlow( - id = "Flow_1gsz7wd", - name = "No", - sourceRef = "gateway_hasSubscribers", - targetRef = "endEvent_noSubscribers", - condition = $$"""${subscribers.size() > 0}""", + val FLOW_0_BIANZ_5: BpmnFlow = BpmnFlow( + id = "Flow_0bianz5", + sourceRef = "startEvent_editionCreated", + targetRef = "serviceTask_loadSubscribers", ) - val FLOW_1_RUAYVL: BpmnFlow = BpmnFlow( - id = "Flow_1ruayvl", - sourceRef = "serviceTask_sendToSubscriber", - targetRef = "serviceTask_notifyAuthor", + val FLOW_0_ENJKOE: BpmnFlow = BpmnFlow( + id = "Flow_0enjkoe", + sourceRef = "eventGateway_afterSendingAgain", + targetRef = "timer_noRejectionForOneDay", ) val FLOW_0_V_2_V_55_N: BpmnFlow = BpmnFlow( @@ -167,20 +163,24 @@ object SendNewsletterProcessApi { targetRef = "serviceTask_analyzeError", ) + val FLOW_0_VYM_6_NU: BpmnFlow = BpmnFlow( + id = "Flow_0vym6nu", + sourceRef = "serviceTask_sendMailAgain", + targetRef = "eventGateway_afterSendingAgain", + ) + + val FLOW_0_X_9_THPQ: BpmnFlow = BpmnFlow( + id = "Flow_0x9thpq", + sourceRef = "event_mailRejectedAgain", + targetRef = "escalationEndEvent_nofitySupportAfterRepeatedError", + ) + val FLOW_13_NMNAG: BpmnFlow = BpmnFlow( id = "Flow_13nmnag", sourceRef = "serviceTask_analyzeError", targetRef = "gateway_canSendAgain", ) - val FLOW_1_IZUCOF: BpmnFlow = BpmnFlow( - id = "Flow_1izucof", - name = "Yes", - sourceRef = "gateway_canSendAgain", - targetRef = "serviceTask_sendMailAgain", - isDefault = true, - ) - val FLOW_18_NF_2_JH: BpmnFlow = BpmnFlow( id = "Flow_18nf2jh", name = "No", @@ -189,34 +189,34 @@ object SendNewsletterProcessApi { condition = $$"""${rejection.reason == "PERMANENT"}""", ) - val FLOW_0_VYM_6_NU: BpmnFlow = BpmnFlow( - id = "Flow_0vym6nu", - sourceRef = "serviceTask_sendMailAgain", - targetRef = "eventGateway_afterSendingAgain", - ) - - val FLOW_0_ENJKOE: BpmnFlow = BpmnFlow( - id = "Flow_0enjkoe", - sourceRef = "eventGateway_afterSendingAgain", - targetRef = "timer_noRejectionForOneDay", + val FLOW_1_GSZ_7_WD: BpmnFlow = BpmnFlow( + id = "Flow_1gsz7wd", + name = "No", + sourceRef = "gateway_hasSubscribers", + targetRef = "endEvent_noSubscribers", + condition = $$"""${subscribers.size() > 0}""", ) - val FLOW_081_CYKL: BpmnFlow = BpmnFlow( - id = "Flow_081cykl", - sourceRef = "eventGateway_afterSendingAgain", - targetRef = "event_mailRejectedAgain", + val FLOW_1_IZUCOF: BpmnFlow = BpmnFlow( + id = "Flow_1izucof", + name = "Yes", + sourceRef = "gateway_canSendAgain", + targetRef = "serviceTask_sendMailAgain", + isDefault = true, ) - val FLOW_0_X_9_THPQ: BpmnFlow = BpmnFlow( - id = "Flow_0x9thpq", - sourceRef = "event_mailRejectedAgain", - targetRef = "escalationEndEvent_nofitySupportAfterRepeatedError", + val FLOW_1_JOGUT_0: BpmnFlow = BpmnFlow( + id = "Flow_1jogut0", + name = "Yes", + sourceRef = "gateway_hasSubscribers", + targetRef = "serviceTask_sendToSubscriber", + isDefault = true, ) - val FLOW_0338_XZF: BpmnFlow = BpmnFlow( - id = "Flow_0338xzf", - sourceRef = "timer_noRejectionForOneDay", - targetRef = "endEvent_issueResolved", + val FLOW_1_RUAYVL: BpmnFlow = BpmnFlow( + id = "Flow_1ruayvl", + sourceRef = "serviceTask_sendToSubscriber", + targetRef = "serviceTask_notifyAuthor", ) } @@ -286,7 +286,7 @@ object SendNewsletterProcessApi { parentId = null, attachedToRef = null, attachedElements = emptyList(), - elementType = "SUB_PROCESS", + elementType = "EVENT_SUB_PROCESS", ) val EVENT_MAIL_REJECTED: BpmnRelations = BpmnRelations( diff --git a/bpmn-to-code-core/src/test/resources/api/NewsletterSubscriptionProcessApiJava.txt b/bpmn-to-code-core/src/test/resources/api/NewsletterSubscriptionProcessApiJava.txt index 8689c5dc..93b73c66 100644 --- a/bpmn-to-code-core/src/test/resources/api/NewsletterSubscriptionProcessApiJava.txt +++ b/bpmn-to-code-core/src/test/resources/api/NewsletterSubscriptionProcessApiJava.txt @@ -26,19 +26,15 @@ public final class NewsletterSubscriptionProcessApi { * Worker runtime code rarely needs these. */ public static final class Elements { - public static final ElementId CALL_ACTIVITY_ABORT_REGISTRATION = new ElementId("CallActivity_AbortRegistration"); - public static final ElementId ACTIVITY_CONFIRM_REGISTRATION = new ElementId("Activity_ConfirmRegistration"); + public static final ElementId ACTIVITY_NOTIFY_COMMUNITY = new ElementId("Activity_NotifyCommunity"); + public static final ElementId ACTIVITY_SEND_CONFIRMATION_MAIL = new ElementId("Activity_SendConfirmationMail"); public static final ElementId ACTIVITY_SEND_WELCOME_MAIL = new ElementId("Activity_SendWelcomeMail"); - public static final ElementId ACTIVITY_NOTIFY_COMMUNITY = new ElementId("Activity_NotifyCommunity"); - - public static final ElementId GATEWAY_SPLIT_NOTIFICATIONS = new ElementId("Gateway_SplitNotifications"); - - public static final ElementId GATEWAY_JOIN_NOTIFICATIONS = new ElementId("Gateway_JoinNotifications"); + public static final ElementId CALL_ACTIVITY_ABORT_REGISTRATION = new ElementId("CallActivity_AbortRegistration"); public static final ElementId COMPENSATION_END_EVENT_REGISTRATION_ABORTED = new ElementId("CompensationEndEvent_RegistrationAborted"); @@ -54,7 +50,9 @@ public final class NewsletterSubscriptionProcessApi { public static final ElementId ERROR_EVENT_INVALID_MAIL = new ElementId("ErrorEvent_InvalidMail"); - public static final ElementId SERVICE_TASK_INCREMENT_SUBSCRIPTION_COUNTER = new ElementId("serviceTask_incrementSubscriptionCounter"); + public static final ElementId GATEWAY_JOIN_NOTIFICATIONS = new ElementId("Gateway_JoinNotifications"); + + public static final ElementId GATEWAY_SPLIT_NOTIFICATIONS = new ElementId("Gateway_SplitNotifications"); public static final ElementId START_EVENT_REQUEST_RECEIVED = new ElementId("StartEvent_RequestReceived"); @@ -65,6 +63,8 @@ public final class NewsletterSubscriptionProcessApi { public static final ElementId TIMER_AFTER_3_DAYS = new ElementId("Timer_After3Days"); public static final ElementId TIMER_EVERY_DAY = new ElementId("Timer_EveryDay"); + + public static final ElementId SERVICE_TASK_INCREMENT_SUBSCRIPTION_COUNTER = new ElementId("serviceTask_incrementSubscriptionCounter"); } /** diff --git a/bpmn-to-code-core/src/test/resources/api/NewsletterSubscriptionProcessApiKotlin.txt b/bpmn-to-code-core/src/test/resources/api/NewsletterSubscriptionProcessApiKotlin.txt index f5e7e39e..253c64b1 100644 --- a/bpmn-to-code-core/src/test/resources/api/NewsletterSubscriptionProcessApiKotlin.txt +++ b/bpmn-to-code-core/src/test/resources/api/NewsletterSubscriptionProcessApiKotlin.txt @@ -28,21 +28,17 @@ object NewsletterSubscriptionProcessApi { * Worker runtime code rarely needs these. */ object Elements { - val CALL_ACTIVITY_ABORT_REGISTRATION: ElementId = - ElementId("CallActivity_AbortRegistration") - val ACTIVITY_CONFIRM_REGISTRATION: ElementId = ElementId("Activity_ConfirmRegistration") + val ACTIVITY_NOTIFY_COMMUNITY: ElementId = ElementId("Activity_NotifyCommunity") + val ACTIVITY_SEND_CONFIRMATION_MAIL: ElementId = ElementId("Activity_SendConfirmationMail") val ACTIVITY_SEND_WELCOME_MAIL: ElementId = ElementId("Activity_SendWelcomeMail") - val ACTIVITY_NOTIFY_COMMUNITY: ElementId = ElementId("Activity_NotifyCommunity") - - val GATEWAY_SPLIT_NOTIFICATIONS: ElementId = ElementId("Gateway_SplitNotifications") - - val GATEWAY_JOIN_NOTIFICATIONS: ElementId = ElementId("Gateway_JoinNotifications") + val CALL_ACTIVITY_ABORT_REGISTRATION: ElementId = + ElementId("CallActivity_AbortRegistration") val COMPENSATION_END_EVENT_REGISTRATION_ABORTED: ElementId = ElementId("CompensationEndEvent_RegistrationAborted") @@ -64,8 +60,9 @@ object NewsletterSubscriptionProcessApi { val ERROR_EVENT_INVALID_MAIL: ElementId = ElementId("ErrorEvent_InvalidMail") - val SERVICE_TASK_INCREMENT_SUBSCRIPTION_COUNTER: ElementId = - ElementId("serviceTask_incrementSubscriptionCounter") + val GATEWAY_JOIN_NOTIFICATIONS: ElementId = ElementId("Gateway_JoinNotifications") + + val GATEWAY_SPLIT_NOTIFICATIONS: ElementId = ElementId("Gateway_SplitNotifications") val START_EVENT_REQUEST_RECEIVED: ElementId = ElementId("StartEvent_RequestReceived") @@ -77,6 +74,9 @@ object NewsletterSubscriptionProcessApi { val TIMER_AFTER_3_DAYS: ElementId = ElementId("Timer_After3Days") val TIMER_EVERY_DAY: ElementId = ElementId("Timer_EveryDay") + + val SERVICE_TASK_INCREMENT_SUBSCRIPTION_COUNTER: ElementId = + ElementId("serviceTask_incrementSubscriptionCounter") } /** diff --git a/bpmn-to-code-core/src/test/resources/json/MultiVariantNewsletterProcess.json b/bpmn-to-code-core/src/test/resources/json/MultiVariantNewsletterProcess.json index 142b8953..d8acbf60 100644 --- a/bpmn-to-code-core/src/test/resources/json/MultiVariantNewsletterProcess.json +++ b/bpmn-to-code-core/src/test/resources/json/MultiVariantNewsletterProcess.json @@ -1,235 +1,566 @@ { - "processId": "sendNewsletter", - "messages": [ - { - "id": "event_mailRejected", - "name": "Message_MailRejected" - }, - { - "id": "event_mailRejectedAgain", - "name": "Message_MailRejectedAgain" - } - ], - "signals": [], - "errors": [], - "escalations": [ - { - "id": "escalationEndEvent_nofitySupport", - "name": "escalation_notifySupport", - "code": "200" - } - ], + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "sendNewsletter", + "flowNodes": [ + { + "id": "startEvent_editionCreated", + "type": "startEvent", + "outgoing": [ + "Flow_0bianz5" + ] + }, + { + "id": "endEvent_editionSent", + "type": "endEvent", + "incoming": [ + "Flow_0v2v55n" + ] + }, + { + "id": "endEvent_noSubscribers", + "type": "endEvent", + "incoming": [ + "Flow_1gsz7wd" + ] + }, + { + "id": "eventSubProcess_errorHandling", + "type": "subProcess", + "triggeredByEvent": true, + "flowNodes": [ + { + "id": "event_mailRejected", + "type": "startEvent", + "outgoing": [ + "Flow_0vtppnk" + ], + "isInterrupting": true, + "eventDefinitions": [ + { + "type": "message", + "messageRef": "Message_MailRejected" + } + ] + }, + { + "id": "serviceTask_analyzeError", + "type": "serviceTask", + "incoming": [ + "Flow_0vtppnk" + ], + "outgoing": [ + "Flow_13nmnag" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.analyzeSendError" + } + }, + { + "id": "gateway_canSendAgain", + "type": "exclusiveGateway", + "incoming": [ + "Flow_13nmnag" + ], + "outgoing": [ + "Flow_1izucof", + "Flow_18nf2jh" + ], + "default": "Flow_1izucof" + }, + { + "id": "escalationEndEvent_nofitySupport", + "type": "endEvent", + "incoming": [ + "Flow_18nf2jh" + ], + "eventDefinitions": [ + { + "type": "escalation", + "escalationRef": "escalation_notifySupport" + } + ] + }, + { + "id": "serviceTask_sendMailAgain", + "type": "serviceTask", + "incoming": [ + "Flow_1izucof" + ], + "outgoing": [ + "Flow_0vym6nu" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendMailToSubscriber" + } + }, + { + "id": "eventGateway_afterSendingAgain", + "type": "eventBasedGateway", + "incoming": [ + "Flow_0vym6nu" + ], + "outgoing": [ + "Flow_0enjkoe", + "Flow_081cykl" + ] + }, + { + "id": "event_mailRejectedAgain", + "type": "intermediateCatchEvent", + "incoming": [ + "Flow_081cykl" + ], + "outgoing": [ + "Flow_0x9thpq" + ], + "eventDefinitions": [ + { + "type": "message", + "messageRef": "Message_MailRejectedAgain" + } + ] + }, + { + "id": "escalationEndEvent_nofitySupportAfterRepeatedError", + "type": "endEvent", + "incoming": [ + "Flow_0x9thpq" + ] + }, + { + "id": "timer_noRejectionForOneDay", + "type": "intermediateCatchEvent", + "incoming": [ + "Flow_0enjkoe" + ], + "outgoing": [ + "Flow_0338xzf" + ], + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "PT1D" + } + ] + }, + { + "id": "endEvent_issueResolved", + "type": "endEvent", + "incoming": [ + "Flow_0338xzf" + ] + } + ], + "sequenceFlows": [ + { + "id": "Flow_0vtppnk", + "sourceRef": "event_mailRejected", + "targetRef": "serviceTask_analyzeError" + }, + { + "id": "Flow_13nmnag", + "sourceRef": "serviceTask_analyzeError", + "targetRef": "gateway_canSendAgain" + }, + { + "id": "Flow_1izucof", + "sourceRef": "gateway_canSendAgain", + "targetRef": "serviceTask_sendMailAgain", + "name": "Yes" + }, + { + "id": "Flow_18nf2jh", + "sourceRef": "gateway_canSendAgain", + "targetRef": "escalationEndEvent_nofitySupport", + "name": "No", + "conditionExpression": "${rejection.reason == \"PERMANENT\"}" + }, + { + "id": "Flow_0vym6nu", + "sourceRef": "serviceTask_sendMailAgain", + "targetRef": "eventGateway_afterSendingAgain" + }, + { + "id": "Flow_0enjkoe", + "sourceRef": "eventGateway_afterSendingAgain", + "targetRef": "timer_noRejectionForOneDay" + }, + { + "id": "Flow_081cykl", + "sourceRef": "eventGateway_afterSendingAgain", + "targetRef": "event_mailRejectedAgain" + }, + { + "id": "Flow_0x9thpq", + "sourceRef": "event_mailRejectedAgain", + "targetRef": "escalationEndEvent_nofitySupportAfterRepeatedError" + }, + { + "id": "Flow_0338xzf", + "sourceRef": "timer_noRejectionForOneDay", + "targetRef": "endEvent_issueResolved" + } + ] + }, + { + "id": "gateway_hasSubscribers", + "type": "exclusiveGateway", + "incoming": [ + "Flow_04andb8" + ], + "outgoing": [ + "Flow_1jogut0", + "Flow_1gsz7wd" + ], + "default": "Flow_1jogut0" + }, + { + "id": "serviceTask_loadSubscribers", + "type": "serviceTask", + "incoming": [ + "Flow_0bianz5" + ], + "outgoing": [ + "Flow_04andb8" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.loadSubscribers" + }, + "variables": [ + { + "name": "subscribers", + "direction": "OUTPUT" + }, + { + "name": "author", + "direction": "OUTPUT" + } + ] + }, + { + "id": "serviceTask_notifyAuthor", + "type": "serviceTask", + "incoming": [ + "Flow_1ruayvl" + ], + "outgoing": [ + "Flow_0v2v55n" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.notifyAuthor" + } + }, + { + "id": "serviceTask_sendToSubscriber", + "type": "serviceTask", + "incoming": [ + "Flow_1jogut0" + ], + "outgoing": [ + "Flow_1ruayvl" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendMailToSubscriber" + } + } + ] + }, + "definitions": { + "messages": [ + { + "id": "Message_MailRejected", + "name": "Message_MailRejected" + }, + { + "id": "Message_MailRejectedAgain", + "name": "Message_MailRejectedAgain" + } + ], + "escalations": [ + { + "id": "escalation_notifySupport", + "name": "escalation_notifySupport", + "escalationCode": "200" + } + ] + }, "variants": [ { - "variantName": "send", + "name": "send", "flowNodes": [ { "id": "startEvent_editionCreated", - "elementType": "START_EVENT", - "followingElements": [ - "serviceTask_loadSubscribers" + "type": "startEvent", + "outgoing": [ + "Flow_0bianz5" ] }, { "id": "serviceTask_loadSubscribers", - "elementType": "SERVICE_TASK", - "previousElements": [ - "startEvent_editionCreated" + "type": "serviceTask", + "incoming": [ + "Flow_0bianz5" ], - "followingElements": [ - "gateway_hasSubscribers" + "outgoing": [ + "Flow_04andb8" ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.loadSubscribers" + }, "variables": [ - "subscribers", - "author" - ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.loadSubscribers" - } + { + "name": "subscribers", + "direction": "OUTPUT" + }, + { + "name": "author", + "direction": "OUTPUT" + } + ] }, { "id": "gateway_hasSubscribers", - "elementType": "EXCLUSIVE_GATEWAY", - "previousElements": [ - "serviceTask_loadSubscribers" + "type": "exclusiveGateway", + "incoming": [ + "Flow_04andb8" ], - "followingElements": [ - "serviceTask_sendToSubscriber", - "endEvent_noSubscribers" - ] + "outgoing": [ + "Flow_1jogut0", + "Flow_1gsz7wd" + ], + "default": "Flow_1jogut0" }, { "id": "endEvent_noSubscribers", - "elementType": "END_EVENT", - "previousElements": [ - "gateway_hasSubscribers" + "type": "endEvent", + "incoming": [ + "Flow_1gsz7wd" ] }, { "id": "serviceTask_sendToSubscriber", - "elementType": "SERVICE_TASK", - "previousElements": [ - "gateway_hasSubscribers" + "type": "serviceTask", + "incoming": [ + "Flow_1jogut0" ], - "followingElements": [ - "serviceTask_notifyAuthor" + "outgoing": [ + "Flow_1ruayvl" ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.sendMailToSubscriber" + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendMailToSubscriber" } }, { "id": "serviceTask_notifyAuthor", - "elementType": "SERVICE_TASK", - "previousElements": [ - "serviceTask_sendToSubscriber" + "type": "serviceTask", + "incoming": [ + "Flow_1ruayvl" ], - "followingElements": [ - "endEvent_editionSent" + "outgoing": [ + "Flow_0v2v55n" ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.notifyAuthor" + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.notifyAuthor" } }, { "id": "endEvent_editionSent", - "elementType": "END_EVENT", - "previousElements": [ - "serviceTask_notifyAuthor" + "type": "endEvent", + "incoming": [ + "Flow_0v2v55n" ] }, { "id": "eventSubProcess_errorHandling", - "elementType": "SUB_PROCESS" - }, - { - "id": "event_mailRejected", - "elementType": "MESSAGE_START_EVENT", - "parentId": "eventSubProcess_errorHandling", - "interrupting": true, - "followingElements": [ - "serviceTask_analyzeError" - ], - "properties": { - "type": "MessageEvent", - "messageName": "Message_MailRejected", - "messageDirection": "CATCH", - "engineSpecificProperties": { - "customExtension": "customValue" + "type": "subProcess", + "triggeredByEvent": true, + "flowNodes": [ + { + "id": "event_mailRejected", + "type": "startEvent", + "outgoing": [ + "Flow_0vtppnk" + ], + "isInterrupting": true, + "eventDefinitions": [ + { + "type": "message", + "messageRef": "Message_MailRejected" + } + ] + }, + { + "id": "serviceTask_analyzeError", + "type": "serviceTask", + "incoming": [ + "Flow_0vtppnk" + ], + "outgoing": [ + "Flow_13nmnag" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.analyzeSendError" + } + }, + { + "id": "gateway_canSendAgain", + "type": "exclusiveGateway", + "incoming": [ + "Flow_13nmnag" + ], + "outgoing": [ + "Flow_1izucof", + "Flow_18nf2jh" + ], + "default": "Flow_1izucof" + }, + { + "id": "escalationEndEvent_nofitySupport", + "type": "endEvent", + "incoming": [ + "Flow_18nf2jh" + ], + "eventDefinitions": [ + { + "type": "escalation", + "escalationRef": "escalation_notifySupport" + } + ] + }, + { + "id": "serviceTask_sendMailAgain", + "type": "serviceTask", + "incoming": [ + "Flow_1izucof" + ], + "outgoing": [ + "Flow_0vym6nu" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendMailToSubscriber" + } + }, + { + "id": "eventGateway_afterSendingAgain", + "type": "eventBasedGateway", + "incoming": [ + "Flow_0vym6nu" + ], + "outgoing": [ + "Flow_0enjkoe", + "Flow_081cykl" + ] + }, + { + "id": "event_mailRejectedAgain", + "type": "intermediateCatchEvent", + "incoming": [ + "Flow_081cykl" + ], + "outgoing": [ + "Flow_0x9thpq" + ], + "eventDefinitions": [ + { + "type": "message", + "messageRef": "Message_MailRejectedAgain" + } + ] + }, + { + "id": "escalationEndEvent_nofitySupportAfterRepeatedError", + "type": "endEvent", + "incoming": [ + "Flow_0x9thpq" + ] + }, + { + "id": "timer_noRejectionForOneDay", + "type": "intermediateCatchEvent", + "incoming": [ + "Flow_0enjkoe" + ], + "outgoing": [ + "Flow_0338xzf" + ], + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "PT1D" + } + ] + }, + { + "id": "endEvent_issueResolved", + "type": "endEvent", + "incoming": [ + "Flow_0338xzf" + ] } - } - }, - { - "id": "serviceTask_analyzeError", - "elementType": "SERVICE_TASK", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "event_mailRejected" - ], - "followingElements": [ - "gateway_canSendAgain" - ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.analyzeSendError" - } - }, - { - "id": "gateway_canSendAgain", - "elementType": "EXCLUSIVE_GATEWAY", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "serviceTask_analyzeError" - ], - "followingElements": [ - "serviceTask_sendMailAgain", - "escalationEndEvent_nofitySupport" - ] - }, - { - "id": "escalationEndEvent_nofitySupport", - "elementType": "ESCALATION_END_EVENT", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "gateway_canSendAgain" - ] - }, - { - "id": "serviceTask_sendMailAgain", - "elementType": "SERVICE_TASK", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "gateway_canSendAgain" - ], - "followingElements": [ - "eventGateway_afterSendingAgain" - ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.sendMailToSubscriber" - } - }, - { - "id": "eventGateway_afterSendingAgain", - "elementType": "EVENT_BASED_GATEWAY", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "serviceTask_sendMailAgain" - ], - "followingElements": [ - "timer_noRejectionForOneDay", - "event_mailRejectedAgain" - ] - }, - { - "id": "event_mailRejectedAgain", - "elementType": "MESSAGE_INTERMEDIATE_CATCH_EVENT", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "eventGateway_afterSendingAgain" - ], - "followingElements": [ - "escalationEndEvent_nofitySupportAfterRepeatedError" - ], - "properties": { - "type": "MessageEvent", - "messageName": "Message_MailRejectedAgain", - "messageDirection": "CATCH" - } - }, - { - "id": "escalationEndEvent_nofitySupportAfterRepeatedError", - "elementType": "END_EVENT", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "event_mailRejectedAgain" - ] - }, - { - "id": "timer_noRejectionForOneDay", - "elementType": "TIMER_INTERMEDIATE_CATCH_EVENT", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "eventGateway_afterSendingAgain" ], - "followingElements": [ - "endEvent_issueResolved" - ], - "properties": { - "type": "Timer", - "timerType": "Duration", - "timerValue": "PT1D" - } - }, - { - "id": "endEvent_issueResolved", - "elementType": "END_EVENT", - "parentId": "eventSubProcess_errorHandling", - "previousElements": [ - "timer_noRejectionForOneDay" + "sequenceFlows": [ + { + "id": "Flow_0vtppnk", + "sourceRef": "event_mailRejected", + "targetRef": "serviceTask_analyzeError" + }, + { + "id": "Flow_13nmnag", + "sourceRef": "serviceTask_analyzeError", + "targetRef": "gateway_canSendAgain" + }, + { + "id": "Flow_1izucof", + "sourceRef": "gateway_canSendAgain", + "targetRef": "serviceTask_sendMailAgain", + "name": "Yes" + }, + { + "id": "Flow_18nf2jh", + "sourceRef": "gateway_canSendAgain", + "targetRef": "escalationEndEvent_nofitySupport", + "name": "No", + "conditionExpression": "${rejection.reason == \"PERMANENT\"}" + }, + { + "id": "Flow_0vym6nu", + "sourceRef": "serviceTask_sendMailAgain", + "targetRef": "eventGateway_afterSendingAgain" + }, + { + "id": "Flow_0enjkoe", + "sourceRef": "eventGateway_afterSendingAgain", + "targetRef": "timer_noRejectionForOneDay" + }, + { + "id": "Flow_081cykl", + "sourceRef": "eventGateway_afterSendingAgain", + "targetRef": "event_mailRejectedAgain" + }, + { + "id": "Flow_0x9thpq", + "sourceRef": "event_mailRejectedAgain", + "targetRef": "escalationEndEvent_nofitySupportAfterRepeatedError" + }, + { + "id": "Flow_0338xzf", + "sourceRef": "timer_noRejectionForOneDay", + "targetRef": "endEvent_issueResolved" + } ] } ], @@ -237,100 +568,37 @@ { "id": "Flow_0bianz5", "sourceRef": "startEvent_editionCreated", - "targetRef": "serviceTask_loadSubscribers", - "isDefault": false + "targetRef": "serviceTask_loadSubscribers" }, { "id": "Flow_04andb8", "sourceRef": "serviceTask_loadSubscribers", - "targetRef": "gateway_hasSubscribers", - "isDefault": false + "targetRef": "gateway_hasSubscribers" }, { "id": "Flow_1jogut0", "sourceRef": "gateway_hasSubscribers", "targetRef": "serviceTask_sendToSubscriber", - "name": "Yes", - "isDefault": true + "name": "Yes" }, { "id": "Flow_1gsz7wd", "sourceRef": "gateway_hasSubscribers", "targetRef": "endEvent_noSubscribers", "name": "No", - "conditionExpression": "${subscribers.size() > 0}", - "isDefault": false + "conditionExpression": "${subscribers.size() > 0}" }, { "id": "Flow_1ruayvl", "sourceRef": "serviceTask_sendToSubscriber", - "targetRef": "serviceTask_notifyAuthor", - "isDefault": false + "targetRef": "serviceTask_notifyAuthor" }, { "id": "Flow_0v2v55n", "sourceRef": "serviceTask_notifyAuthor", - "targetRef": "endEvent_editionSent", - "isDefault": false - }, - { - "id": "Flow_0vtppnk", - "sourceRef": "event_mailRejected", - "targetRef": "serviceTask_analyzeError", - "isDefault": false - }, - { - "id": "Flow_13nmnag", - "sourceRef": "serviceTask_analyzeError", - "targetRef": "gateway_canSendAgain", - "isDefault": false - }, - { - "id": "Flow_1izucof", - "sourceRef": "gateway_canSendAgain", - "targetRef": "serviceTask_sendMailAgain", - "name": "Yes", - "isDefault": true - }, - { - "id": "Flow_18nf2jh", - "sourceRef": "gateway_canSendAgain", - "targetRef": "escalationEndEvent_nofitySupport", - "name": "No", - "conditionExpression": "${rejection.reason == \"PERMANENT\"}", - "isDefault": false - }, - { - "id": "Flow_0vym6nu", - "sourceRef": "serviceTask_sendMailAgain", - "targetRef": "eventGateway_afterSendingAgain", - "isDefault": false - }, - { - "id": "Flow_0enjkoe", - "sourceRef": "eventGateway_afterSendingAgain", - "targetRef": "timer_noRejectionForOneDay", - "isDefault": false - }, - { - "id": "Flow_081cykl", - "sourceRef": "eventGateway_afterSendingAgain", - "targetRef": "event_mailRejectedAgain", - "isDefault": false - }, - { - "id": "Flow_0x9thpq", - "sourceRef": "event_mailRejectedAgain", - "targetRef": "escalationEndEvent_nofitySupportAfterRepeatedError", - "isDefault": false - }, - { - "id": "Flow_0338xzf", - "sourceRef": "timer_noRejectionForOneDay", - "targetRef": "endEvent_issueResolved", - "isDefault": false + "targetRef": "endEvent_editionSent" } ] } ] -} \ No newline at end of file +} diff --git a/bpmn-to-code-core/src/test/resources/json/NewsletterSubscriptionProcess.json b/bpmn-to-code-core/src/test/resources/json/NewsletterSubscriptionProcess.json index 7bb33ce3..50a5cee8 100644 --- a/bpmn-to-code-core/src/test/resources/json/NewsletterSubscriptionProcess.json +++ b/bpmn-to-code-core/src/test/resources/json/NewsletterSubscriptionProcess.json @@ -1,407 +1,422 @@ { - "processId": "newsletterSubscription", - "flowNodes": [ - { - "id": "StartEvent_SubmitRegistrationForm", - "displayName": "Submit newsletter form", - "elementType": "MESSAGE_START_EVENT", - "followingElements": [ - "serviceTask_incrementSubscriptionCounter" - ], - "variables": [ - "subscriptionId" - ], - "properties": { - "type": "MessageEvent", - "messageName": "Message_FormSubmitted", - "messageDirection": "CATCH" - } - }, - { - "id": "serviceTask_incrementSubscriptionCounter", - "displayName": "Increment subscription counter", - "elementType": "SERVICE_TASK", - "attachedElements": [ - "CompensationEvent_OnSubscriptionCounter" - ], - "previousElements": [ - "StartEvent_SubmitRegistrationForm" - ], - "followingElements": [ - "SubProcess_Confirmation" - ], - "properties": { - "type": "ServiceTask", - "implementationValue": "counterClass" - } - }, - { - "id": "CompensationEvent_OnSubscriptionCounter", - "displayName": "Registration aborted", - "elementType": "COMPENSATION_BOUNDARY_EVENT", - "attachedToRef": "serviceTask_incrementSubscriptionCounter", - "interrupting": true - }, - { - "id": "SubProcess_Confirmation", - "displayName": "Subscription Confirmation", - "elementType": "SUB_PROCESS", - "attachedElements": [ - "ErrorEvent_InvalidMail", - "Timer_After3Days" - ], - "previousElements": [ - "serviceTask_incrementSubscriptionCounter" - ], - "followingElements": [ - "Gateway_SplitNotifications" - ] - }, - { - "id": "StartEvent_RequestReceived", - "displayName": "Subscription requested", - "elementType": "START_EVENT", - "parentId": "SubProcess_Confirmation", - "followingElements": [ - "Activity_SendConfirmationMail" - ], - "variables": [ - "subscriptionId" - ] - }, - { - "id": "Activity_SendConfirmationMail", - "displayName": "Send confirmation mail", - "elementType": "SERVICE_TASK", - "parentId": "SubProcess_Confirmation", - "previousElements": [ - "StartEvent_RequestReceived", - "Timer_EveryDay" - ], - "followingElements": [ - "Activity_ConfirmRegistration" - ], - "variables": [ - "subscriptionId" - ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.sendConfirmationMail" - } - }, - { - "id": "Activity_ConfirmRegistration", - "displayName": "Confirm subscription", - "elementType": "USER_TASK", - "parentId": "SubProcess_Confirmation", - "attachedElements": [ - "Timer_EveryDay" - ], - "previousElements": [ - "Activity_SendConfirmationMail" - ], - "followingElements": [ - "EndEvent_SubscriptionConfirmed" - ] - }, - { - "id": "Timer_EveryDay", - "displayName": "Every day", - "elementType": "TIMER_BOUNDARY_EVENT", - "parentId": "SubProcess_Confirmation", - "attachedToRef": "Activity_ConfirmRegistration", - "interrupting": false, - "followingElements": [ - "Activity_SendConfirmationMail" - ], - "properties": { - "type": "Timer", - "timerType": "Duration", - "timerValue": "PT1M" - } - }, - { - "id": "EndEvent_SubscriptionConfirmed", - "displayName": "Subscription confirmed", - "elementType": "END_EVENT", - "parentId": "SubProcess_Confirmation", - "previousElements": [ - "Activity_ConfirmRegistration" - ] - }, - { - "id": "ErrorEvent_InvalidMail", - "displayName": "Invalid Mail", - "elementType": "ERROR_BOUNDARY_EVENT", - "attachedToRef": "SubProcess_Confirmation", - "interrupting": true, - "followingElements": [ - "EndEvent_RegistrationNotPossible" - ] - }, - { - "id": "EndEvent_RegistrationNotPossible", - "displayName": "Registration not possible", - "elementType": "SIGNAL_END_EVENT", - "previousElements": [ - "ErrorEvent_InvalidMail" - ], - "properties": { - "type": "SignalEvent", - "signalName": "Signal_RegistrationNotPossible", - "signalDirection": "THROW" - } - }, - { - "id": "Timer_After3Days", - "displayName": "After 3 days", - "elementType": "TIMER_BOUNDARY_EVENT", - "attachedToRef": "SubProcess_Confirmation", - "interrupting": true, - "followingElements": [ - "CallActivity_AbortRegistration" - ], - "properties": { - "type": "Timer", - "timerType": "Duration", - "timerValue": "${testVariable}" - } - }, - { - "id": "CallActivity_AbortRegistration", - "displayName": "Abort registration", - "elementType": "CALL_ACTIVITY", - "previousElements": [ - "Timer_After3Days" - ], - "followingElements": [ - "CompensationEndEvent_RegistrationAborted" - ], - "variables": [ - "subscriptionId" - ], - "properties": { - "type": "CallActivity", - "calledElement": "abort-registration" + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "newsletterSubscription", + "flowNodes": [ + { + "id": "StartEvent_SubmitRegistrationForm", + "type": "startEvent", + "name": "Submit newsletter form", + "outgoing": [ + "Flow_1csfyyz" + ], + "eventDefinitions": [ + { + "type": "message", + "messageRef": "Message_FormSubmitted" + } + ], + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT" + } + ] + }, + { + "id": "serviceTask_incrementSubscriptionCounter", + "type": "serviceTask", + "name": "Increment subscription counter", + "incoming": [ + "Flow_1csfyyz" + ], + "outgoing": [ + "Flow_0zdmt0t" + ], + "boundaryEventRefs": [ + "CompensationEvent_OnSubscriptionCounter" + ], + "implementation": { + "type": "jobWorker", + "jobType": "counterClass" + } + }, + { + "id": "CompensationEvent_OnSubscriptionCounter", + "type": "boundaryEvent", + "name": "Registration aborted", + "attachedToRef": "serviceTask_incrementSubscriptionCounter", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "compensation" + } + ] + }, + { + "id": "SubProcess_Confirmation", + "type": "subProcess", + "name": "Subscription Confirmation", + "incoming": [ + "Flow_0zdmt0t" + ], + "outgoing": [ + "Flow_09cuvzp" + ], + "boundaryEventRefs": [ + "ErrorEvent_InvalidMail", + "Timer_After3Days" + ], + "flowNodes": [ + { + "id": "StartEvent_RequestReceived", + "type": "startEvent", + "name": "Subscription requested", + "outgoing": [ + "Flow_05i3x1y" + ], + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT" + } + ] + }, + { + "id": "Activity_SendConfirmationMail", + "type": "serviceTask", + "name": "Send confirmation mail", + "incoming": [ + "Flow_05i3x1y", + "Flow_0x4ewvb" + ], + "outgoing": [ + "Flow_1bckm43" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendConfirmationMail" + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT" + } + ] + }, + { + "id": "Activity_ConfirmRegistration", + "type": "userTask", + "name": "Confirm subscription", + "incoming": [ + "Flow_1bckm43" + ], + "outgoing": [ + "Flow_1cpwe57" + ], + "boundaryEventRefs": [ + "Timer_EveryDay" + ] + }, + { + "id": "Timer_EveryDay", + "type": "boundaryEvent", + "name": "Every day", + "outgoing": [ + "Flow_0x4ewvb" + ], + "attachedToRef": "Activity_ConfirmRegistration", + "cancelActivity": false, + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "PT1M" + } + ] + }, + { + "id": "EndEvent_SubscriptionConfirmed", + "type": "endEvent", + "name": "Subscription confirmed", + "incoming": [ + "Flow_1cpwe57" + ] + } + ], + "sequenceFlows": [ + { + "id": "Flow_05i3x1y", + "sourceRef": "StartEvent_RequestReceived", + "targetRef": "Activity_SendConfirmationMail" + }, + { + "id": "Flow_0x4ewvb", + "sourceRef": "Timer_EveryDay", + "targetRef": "Activity_SendConfirmationMail" + }, + { + "id": "Flow_1bckm43", + "sourceRef": "Activity_SendConfirmationMail", + "targetRef": "Activity_ConfirmRegistration" + }, + { + "id": "Flow_1cpwe57", + "sourceRef": "Activity_ConfirmRegistration", + "targetRef": "EndEvent_SubscriptionConfirmed" + } + ] + }, + { + "id": "ErrorEvent_InvalidMail", + "type": "boundaryEvent", + "name": "Invalid Mail", + "outgoing": [ + "Flow_0i2ctuv" + ], + "attachedToRef": "SubProcess_Confirmation", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "error", + "errorRef": "Error_InvalidMail" + } + ] + }, + { + "id": "EndEvent_RegistrationNotPossible", + "type": "endEvent", + "name": "Registration not possible", + "incoming": [ + "Flow_0i2ctuv" + ], + "eventDefinitions": [ + { + "type": "signal", + "signalRef": "Signal_RegistrationNotPossible" + } + ] + }, + { + "id": "Timer_After3Days", + "type": "boundaryEvent", + "name": "After 3 days", + "outgoing": [ + "Flow_1l1lj4m" + ], + "attachedToRef": "SubProcess_Confirmation", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "${testVariable}" + } + ] + }, + { + "id": "CallActivity_AbortRegistration", + "type": "callActivity", + "name": "Abort registration", + "incoming": [ + "Flow_1l1lj4m" + ], + "outgoing": [ + "Flow_1bsb8no" + ], + "calledElement": { + "processId": "abort-registration" + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT" + } + ] + }, + { + "id": "CompensationEndEvent_RegistrationAborted", + "type": "endEvent", + "name": "Registration aborted", + "incoming": [ + "Flow_1bsb8no" + ], + "eventDefinitions": [ + { + "type": "compensation" + } + ] + }, + { + "id": "Gateway_SplitNotifications", + "type": "parallelGateway", + "incoming": [ + "Flow_09cuvzp" + ], + "outgoing": [ + "Flow_16hub0n", + "Flow_1p5t47z" + ] + }, + { + "id": "Activity_NotifyCommunity", + "type": "serviceTask", + "name": "Notify community", + "incoming": [ + "Flow_1p5t47z" + ], + "outgoing": [ + "Flow_1duwy83" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.notifyCommunity" + }, + "engineAttributes": { + "camunda:asyncBefore": true, + "camunda:asyncAfter": true, + "camunda:exclusive": false + } + }, + { + "id": "Gateway_JoinNotifications", + "type": "parallelGateway", + "incoming": [ + "Flow_1i7hjid", + "Flow_1duwy83" + ], + "outgoing": [ + "Flow_1862jd8" + ] + }, + { + "id": "EndEvent_RegistrationCompleted", + "type": "endEvent", + "name": "Registration completed", + "incoming": [ + "Flow_1862jd8" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.registrationCompleted" + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT" + } + ] + }, + { + "id": "Activity_SendWelcomeMail", + "type": "serviceTask", + "name": "Send Welcome-Mail", + "incoming": [ + "Flow_16hub0n" + ], + "outgoing": [ + "Flow_1i7hjid" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendWelcomeMail" + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT" + } + ], + "engineAttributes": { + "camunda:asyncBefore": true, + "camunda:asyncAfter": true, + "camunda:exclusive": false + } + }, + { + "id": "CompensationTask_DecrementSubscriptionCounter", + "type": "task", + "name": "Decrement subscription counter" } - }, - { - "id": "CompensationEndEvent_RegistrationAborted", - "displayName": "Registration aborted", - "elementType": "COMPENSATION_END_EVENT", - "previousElements": [ - "CallActivity_AbortRegistration" - ] - }, - { - "id": "Gateway_SplitNotifications", - "elementType": "PARALLEL_GATEWAY", - "previousElements": [ - "SubProcess_Confirmation" - ], - "followingElements": [ - "Activity_SendWelcomeMail", - "Activity_NotifyCommunity" - ] - }, - { - "id": "Activity_NotifyCommunity", - "displayName": "Notify community", - "elementType": "SERVICE_TASK", - "previousElements": [ - "Gateway_SplitNotifications" - ], - "followingElements": [ - "Gateway_JoinNotifications" - ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.notifyCommunity" + ], + "sequenceFlows": [ + { + "id": "Flow_09cuvzp", + "sourceRef": "SubProcess_Confirmation", + "targetRef": "Gateway_SplitNotifications" + }, + { + "id": "Flow_0i2ctuv", + "sourceRef": "ErrorEvent_InvalidMail", + "targetRef": "EndEvent_RegistrationNotPossible" + }, + { + "id": "Flow_0zdmt0t", + "sourceRef": "serviceTask_incrementSubscriptionCounter", + "targetRef": "SubProcess_Confirmation" + }, + { + "id": "Flow_16hub0n", + "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_SendWelcomeMail" }, - "engineSpecificProperties": { - "asyncBefore": true, - "asyncAfter": true, - "exclusive": false + { + "id": "Flow_1862jd8", + "sourceRef": "Gateway_JoinNotifications", + "targetRef": "EndEvent_RegistrationCompleted" + }, + { + "id": "Flow_1bsb8no", + "sourceRef": "CallActivity_AbortRegistration", + "targetRef": "CompensationEndEvent_RegistrationAborted" + }, + { + "id": "Flow_1csfyyz", + "sourceRef": "StartEvent_SubmitRegistrationForm", + "targetRef": "serviceTask_incrementSubscriptionCounter" + }, + { + "id": "Flow_1duwy83", + "sourceRef": "Activity_NotifyCommunity", + "targetRef": "Gateway_JoinNotifications" + }, + { + "id": "Flow_1i7hjid", + "sourceRef": "Activity_SendWelcomeMail", + "targetRef": "Gateway_JoinNotifications" + }, + { + "id": "Flow_1l1lj4m", + "sourceRef": "Timer_After3Days", + "targetRef": "CallActivity_AbortRegistration" + }, + { + "id": "Flow_1p5t47z", + "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_NotifyCommunity" } - }, - { - "id": "Gateway_JoinNotifications", - "elementType": "PARALLEL_GATEWAY", - "previousElements": [ - "Activity_SendWelcomeMail", - "Activity_NotifyCommunity" - ], - "followingElements": [ - "EndEvent_RegistrationCompleted" - ] - }, - { - "id": "EndEvent_RegistrationCompleted", - "displayName": "Registration completed", - "elementType": "END_EVENT", - "previousElements": [ - "Gateway_JoinNotifications" - ], - "variables": [ - "subscriptionId" - ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.registrationCompleted" + ] + }, + "definitions": { + "messages": [ + { + "id": "Message_FormSubmitted", + "name": "Message_FormSubmitted" } - }, - { - "id": "Activity_SendWelcomeMail", - "displayName": "Send Welcome-Mail", - "elementType": "SERVICE_TASK", - "previousElements": [ - "Gateway_SplitNotifications" - ], - "followingElements": [ - "Gateway_JoinNotifications" - ], - "variables": [ - "subscriptionId" - ], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.sendWelcomeMail" - }, - "engineSpecificProperties": { - "asyncBefore": true, - "asyncAfter": true, - "exclusive": false + ], + "signals": [ + { + "id": "Signal_RegistrationNotPossible", + "name": "Signal_RegistrationNotPossible" } - }, - { - "id": "CompensationTask_DecrementSubscriptionCounter", - "displayName": "Decrement subscription counter", - "elementType": "SERVICE_TASK", - "properties": { - "type": "ServiceTask", - "implementationValue": "counterClass" + ], + "errors": [ + { + "id": "Error_InvalidMail", + "name": "Error_InvalidMail", + "errorCode": "500" } - } - ], - "messages": [ - { - "id": "StartEvent_SubmitRegistrationForm", - "name": "Message_FormSubmitted" - } - ], - "signals": [ - { - "id": "EndEvent_RegistrationNotPossible", - "name": "Signal_RegistrationNotPossible" - } - ], - "errors": [ - { - "id": "ErrorEvent_InvalidMail", - "name": "Error_InvalidMail", - "code": "500" - } - ], - "compensations": [ - { - "id": "CompensationEndEvent_RegistrationAborted", - "activityRef": "CompensationEndEvent_RegistrationAborted" - }, - { - "id": "CompensationEvent_OnSubscriptionCounter", - "activityRef": "CompensationEvent_OnSubscriptionCounter" - } - ], - "sequenceFlows": [ - { - "id": "Flow_05i3x1y", - "sourceRef": "StartEvent_RequestReceived", - "targetRef": "Activity_SendConfirmationMail", - "isDefault": false - }, - { - "id": "Flow_09cuvzp", - "sourceRef": "SubProcess_Confirmation", - "targetRef": "Gateway_SplitNotifications", - "isDefault": false - }, - { - "id": "Flow_0i2ctuv", - "sourceRef": "ErrorEvent_InvalidMail", - "targetRef": "EndEvent_RegistrationNotPossible", - "isDefault": false - }, - { - "id": "Flow_0x4ewvb", - "sourceRef": "Timer_EveryDay", - "targetRef": "Activity_SendConfirmationMail", - "isDefault": false - }, - { - "id": "Flow_0zdmt0t", - "sourceRef": "serviceTask_incrementSubscriptionCounter", - "targetRef": "SubProcess_Confirmation", - "isDefault": false - }, - { - "id": "Flow_16hub0n", - "sourceRef": "Gateway_SplitNotifications", - "targetRef": "Activity_SendWelcomeMail", - "isDefault": false - }, - { - "id": "Flow_1862jd8", - "sourceRef": "Gateway_JoinNotifications", - "targetRef": "EndEvent_RegistrationCompleted", - "isDefault": false - }, - { - "id": "Flow_1bckm43", - "sourceRef": "Activity_SendConfirmationMail", - "targetRef": "Activity_ConfirmRegistration", - "isDefault": false - }, - { - "id": "Flow_1bsb8no", - "sourceRef": "CallActivity_AbortRegistration", - "targetRef": "CompensationEndEvent_RegistrationAborted", - "isDefault": false - }, - { - "id": "Flow_1cpwe57", - "sourceRef": "Activity_ConfirmRegistration", - "targetRef": "EndEvent_SubscriptionConfirmed", - "isDefault": false - }, - { - "id": "Flow_1csfyyz", - "sourceRef": "StartEvent_SubmitRegistrationForm", - "targetRef": "serviceTask_incrementSubscriptionCounter", - "isDefault": false - }, - { - "id": "Flow_1duwy83", - "sourceRef": "Activity_NotifyCommunity", - "targetRef": "Gateway_JoinNotifications", - "isDefault": false - }, - { - "id": "Flow_1i7hjid", - "sourceRef": "Activity_SendWelcomeMail", - "targetRef": "Gateway_JoinNotifications", - "isDefault": false - }, - { - "id": "Flow_1l1lj4m", - "sourceRef": "Timer_After3Days", - "targetRef": "CallActivity_AbortRegistration", - "isDefault": false - }, - { - "id": "Flow_1p5t47z", - "sourceRef": "Gateway_SplitNotifications", - "targetRef": "Activity_NotifyCommunity", - "isDefault": false - } - ] -} \ No newline at end of file + ] + } +} diff --git a/bpmn-to-code-core/src/test/resources/json/e2e/c7-subscribe-newsletter.json b/bpmn-to-code-core/src/test/resources/json/e2e/c7-subscribe-newsletter.json new file mode 100644 index 00000000..b2042c77 --- /dev/null +++ b/bpmn-to-code-core/src/test/resources/json/e2e/c7-subscribe-newsletter.json @@ -0,0 +1,701 @@ +{ + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "newsletterSubscription", + "engine": "CAMUNDA_7", + "flowNodes": [ + { + "id": "StartEvent_SubmitRegistrationForm", + "type": "startEvent", + "name": "Submit newsletter form", + "outgoing": [ + "Flow_1csfyyz" + ], + "eventDefinitions": [ + { + "type": "message", + "messageRef": "Message_04tc0t0" + } + ], + "ioMapping": { + "outputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "camunda:inputOutput", + "children": [ + { + "$type": "camunda:outputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ] + }, + { + "id": "serviceTask_incrementSubscriptionCounter", + "type": "serviceTask", + "name": "Increment subscription counter", + "incoming": [ + "Flow_1csfyyz" + ], + "outgoing": [ + "Flow_0zdmt0t" + ], + "boundaryEventRefs": [ + "CompensationEvent_OnSubscriptionCounter" + ], + "implementation": { + "type": "delegateExpression", + "expression": "counterClass" + } + }, + { + "id": "CompensationEvent_OnSubscriptionCounter", + "type": "boundaryEvent", + "name": "Registration aborted", + "attachedToRef": "serviceTask_incrementSubscriptionCounter", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "compensation", + "waitForCompletion": false + } + ], + "engineAttributes": { + "camunda:asyncAfter": true + } + }, + { + "id": "SubProcess_Confirmation", + "type": "subProcess", + "name": "Subscription Confirmation", + "incoming": [ + "Flow_0zdmt0t" + ], + "outgoing": [ + "Flow_09cuvzp" + ], + "boundaryEventRefs": [ + "ErrorEvent_InvalidMail", + "Timer_After3Days" + ], + "flowNodes": [ + { + "id": "StartEvent_RequestReceived", + "type": "startEvent", + "name": "Subscription requested", + "outgoing": [ + "Flow_05i3x1y" + ], + "ioMapping": { + "outputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "camunda:inputOutput", + "children": [ + { + "$type": "camunda:outputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ], + "engineAttributes": { + "camunda:asyncBefore": true + } + }, + { + "id": "Activity_SendConfirmationMail", + "type": "serviceTask", + "name": "Send confirmation mail", + "incoming": [ + "Flow_05i3x1y", + "Flow_0x4ewvb" + ], + "outgoing": [ + "Flow_1bckm43" + ], + "implementation": { + "type": "externalTask", + "topic": "#{newsletterSendConfirmationMail}" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + }, + { + "target": "otherVariable", + "source": "dummy" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + }, + { + "name": "otherVariable", + "direction": "INPUT", + "expression": "dummy" + } + ], + "extensions": [ + { + "$type": "camunda:inputOutput", + "children": [ + { + "$type": "camunda:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + }, + { + "$type": "camunda:inputParameter", + "attributes": { + "name": "otherVariable" + }, + "body": "dummy" + } + ] + } + ], + "engineAttributes": { + "camunda:type": "external" + } + }, + { + "id": "Activity_ConfirmRegistration", + "type": "userTask", + "name": "Confirm subscription", + "incoming": [ + "Flow_1bckm43" + ], + "outgoing": [ + "Flow_1cpwe57" + ], + "boundaryEventRefs": [ + "Timer_EveryDay" + ], + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "camunda:inputOutput", + "children": [ + { + "$type": "camunda:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ], + "engineAttributes": { + "camunda:asyncAfter": true + } + }, + { + "id": "Timer_EveryDay", + "type": "boundaryEvent", + "name": "Every day", + "outgoing": [ + "Flow_0x4ewvb" + ], + "attachedToRef": "Activity_ConfirmRegistration", + "cancelActivity": false, + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "PT1M" + } + ] + }, + { + "id": "EndEvent_SubscriptionConfirmed", + "type": "endEvent", + "name": "Subscription confirmed", + "incoming": [ + "Flow_1cpwe57" + ] + } + ], + "sequenceFlows": [ + { + "id": "Flow_05i3x1y", + "sourceRef": "StartEvent_RequestReceived", + "targetRef": "Activity_SendConfirmationMail" + }, + { + "id": "Flow_0x4ewvb", + "sourceRef": "Timer_EveryDay", + "targetRef": "Activity_SendConfirmationMail" + }, + { + "id": "Flow_1bckm43", + "sourceRef": "Activity_SendConfirmationMail", + "targetRef": "Activity_ConfirmRegistration" + }, + { + "id": "Flow_1cpwe57", + "sourceRef": "Activity_ConfirmRegistration", + "targetRef": "EndEvent_SubscriptionConfirmed" + } + ] + }, + { + "id": "ErrorEvent_InvalidMail", + "type": "boundaryEvent", + "name": "Invalid Mail", + "outgoing": [ + "Flow_0i2ctuv" + ], + "attachedToRef": "SubProcess_Confirmation", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "error", + "errorRef": "Error_0uxgmyc" + } + ] + }, + { + "id": "EndEvent_RegistrationNotPossible", + "type": "endEvent", + "name": "Registration not possible", + "incoming": [ + "Flow_0i2ctuv" + ], + "eventDefinitions": [ + { + "type": "signal", + "signalRef": "Signal_14g8ki5" + } + ], + "engineAttributes": { + "camunda:asyncBefore": true, + "camunda:exclusive": false + } + }, + { + "id": "Timer_After3Days", + "type": "boundaryEvent", + "name": "After 3 days", + "outgoing": [ + "Flow_1l1lj4m" + ], + "attachedToRef": "SubProcess_Confirmation", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "${testVariable}" + } + ] + }, + { + "id": "CallActivity_AbortRegistration", + "type": "callActivity", + "name": "Abort registration", + "incoming": [ + "Flow_1l1lj4m" + ], + "outgoing": [ + "Flow_1bsb8no" + ], + "calledElement": { + "processId": "abort-registration" + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "subscriptionId" + }, + { + "name": "reasonCode", + "direction": "INPUT", + "expression": "${reasonCode}" + }, + { + "name": "abortResult", + "direction": "OUTPUT", + "expression": "abortResult" + } + ], + "extensions": [ + { + "$type": "camunda:in", + "attributes": { + "source": "subscriptionId", + "target": "childSubscriptionId" + } + }, + { + "$type": "camunda:in", + "attributes": { + "sourceExpression": "${reasonCode}", + "target": "childReasonCode" + } + }, + { + "$type": "camunda:out", + "attributes": { + "source": "childAbortResult", + "target": "abortResult" + } + } + ], + "engineAttributes": { + "camunda:asyncAfter": true, + "camunda:asyncBefore": true + } + }, + { + "id": "CompensationEndEvent_RegistrationAborted", + "type": "endEvent", + "name": "Registration aborted", + "incoming": [ + "Flow_1bsb8no" + ], + "eventDefinitions": [ + { + "type": "compensation", + "activityRef": "serviceTask_incrementSubscriptionCounter", + "waitForCompletion": false + } + ] + }, + { + "id": "Gateway_SplitNotifications", + "type": "parallelGateway", + "incoming": [ + "Flow_09cuvzp" + ], + "outgoing": [ + "Flow_16hub0n", + "Flow_1p5t47z" + ] + }, + { + "id": "Activity_NotifyCommunity", + "type": "serviceTask", + "name": "Notify community", + "incoming": [ + "Flow_1p5t47z" + ], + "outgoing": [ + "Flow_1duwy83" + ], + "implementation": { + "type": "delegateExpression", + "expression": "${newsletterNotifyCommunity}" + }, + "engineAttributes": { + "camunda:asyncAfter": true, + "camunda:asyncBefore": true, + "camunda:exclusive": false + } + }, + { + "id": "Gateway_JoinNotifications", + "type": "parallelGateway", + "incoming": [ + "Flow_1i7hjid", + "Flow_1duwy83" + ], + "outgoing": [ + "Flow_1862jd8" + ] + }, + { + "id": "EndEvent_RegistrationCompleted", + "type": "endEvent", + "name": "Registration completed", + "incoming": [ + "Flow_1862jd8" + ], + "eventDefinitions": [ + { + "type": "message" + } + ], + "implementation": { + "type": "externalTask", + "topic": "newsletter.registrationCompleted" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "camunda:inputOutput", + "children": [ + { + "$type": "camunda:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ] + }, + { + "id": "Activity_SendWelcomeMail", + "type": "serviceTask", + "name": "Send Welcome-Mail", + "incoming": [ + "Flow_16hub0n" + ], + "outgoing": [ + "Flow_1i7hjid" + ], + "implementation": { + "type": "delegateExpression", + "expression": "${newsletterSendWelcomeMail}" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ], + "outputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + }, + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "camunda:inputOutput", + "children": [ + { + "$type": "camunda:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + }, + { + "$type": "camunda:outputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ], + "engineAttributes": { + "camunda:asyncAfter": true, + "camunda:asyncBefore": true, + "camunda:exclusive": false + } + }, + { + "id": "CompensationTask_DecrementSubscriptionCounter", + "type": "serviceTask", + "name": "Decrement subscription counter", + "isForCompensation": true, + "implementation": { + "type": "delegateExpression", + "expression": "counterClass" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "camunda:inputOutput", + "children": [ + { + "$type": "camunda:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ] + } + ], + "sequenceFlows": [ + { + "id": "Flow_09cuvzp", + "sourceRef": "SubProcess_Confirmation", + "targetRef": "Gateway_SplitNotifications" + }, + { + "id": "Flow_0i2ctuv", + "sourceRef": "ErrorEvent_InvalidMail", + "targetRef": "EndEvent_RegistrationNotPossible" + }, + { + "id": "Flow_0zdmt0t", + "sourceRef": "serviceTask_incrementSubscriptionCounter", + "targetRef": "SubProcess_Confirmation" + }, + { + "id": "Flow_16hub0n", + "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_SendWelcomeMail" + }, + { + "id": "Flow_1862jd8", + "sourceRef": "Gateway_JoinNotifications", + "targetRef": "EndEvent_RegistrationCompleted" + }, + { + "id": "Flow_1bsb8no", + "sourceRef": "CallActivity_AbortRegistration", + "targetRef": "CompensationEndEvent_RegistrationAborted" + }, + { + "id": "Flow_1csfyyz", + "sourceRef": "StartEvent_SubmitRegistrationForm", + "targetRef": "serviceTask_incrementSubscriptionCounter" + }, + { + "id": "Flow_1duwy83", + "sourceRef": "Activity_NotifyCommunity", + "targetRef": "Gateway_JoinNotifications" + }, + { + "id": "Flow_1i7hjid", + "sourceRef": "Activity_SendWelcomeMail", + "targetRef": "Gateway_JoinNotifications" + }, + { + "id": "Flow_1l1lj4m", + "sourceRef": "Timer_After3Days", + "targetRef": "CallActivity_AbortRegistration" + }, + { + "id": "Flow_1p5t47z", + "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_NotifyCommunity" + } + ] + }, + "definitions": { + "messages": [ + { + "id": "Message_04tc0t0", + "name": "Message_FormSubmitted" + }, + { + "id": "Message_36dkcng", + "name": "Message_SubscriptionConfirmed" + } + ], + "signals": [ + { + "id": "Signal_14g8ki5", + "name": "Signal_RegistrationNotPossible" + } + ], + "errors": [ + { + "id": "Error_0uxgmyc", + "name": "Error_InvalidMail", + "errorCode": "500" + } + ] + } +} \ No newline at end of file diff --git a/bpmn-to-code-core/src/test/resources/json/e2e/c8-subscribe-newsletter.json b/bpmn-to-code-core/src/test/resources/json/e2e/c8-subscribe-newsletter.json new file mode 100644 index 00000000..8c8d20a8 --- /dev/null +++ b/bpmn-to-code-core/src/test/resources/json/e2e/c8-subscribe-newsletter.json @@ -0,0 +1,534 @@ +{ + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "newsletterSubscription", + "engine": "ZEEBE", + "flowNodes": [ + { + "id": "StartEvent_SubmitRegistrationForm", + "type": "startEvent", + "name": "Submit newsletter form", + "outgoing": [ + "Flow_1csfyyz" + ], + "eventDefinitions": [ + { + "type": "message", + "messageRef": "Message_04tc0t0" + } + ], + "ioMapping": { + "outputs": [ + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "=subscriptionId" + } + ] + }, + { + "id": "serviceTask_incrementSubscriptionCounter", + "type": "serviceTask", + "name": "Increment subscription counter", + "incoming": [ + "Flow_1csfyyz" + ], + "outgoing": [ + "Flow_0zdmt0t" + ], + "boundaryEventRefs": [ + "CompensationEvent_OnSubscriptionCounter" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.incrementCounter" + } + }, + { + "id": "CompensationEvent_OnSubscriptionCounter", + "type": "boundaryEvent", + "name": "Registration aborted", + "attachedToRef": "serviceTask_incrementSubscriptionCounter", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "compensation", + "waitForCompletion": false + } + ] + }, + { + "id": "SubProcess_Confirmation", + "type": "subProcess", + "name": "Subscription Confirmation", + "incoming": [ + "Flow_0zdmt0t" + ], + "outgoing": [ + "Flow_09cuvzp" + ], + "boundaryEventRefs": [ + "ErrorEvent_InvalidMail", + "Timer_After3Days" + ], + "flowNodes": [ + { + "id": "StartEvent_RequestReceived", + "type": "startEvent", + "name": "Subscription requested", + "outgoing": [ + "Flow_05i3x1y" + ], + "ioMapping": { + "outputs": [ + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "=subscriptionId" + } + ] + }, + { + "id": "Activity_SendConfirmationMail", + "type": "serviceTask", + "name": "Send confirmation mail", + "incoming": [ + "Flow_05i3x1y", + "Flow_0x4ewvb" + ], + "outgoing": [ + "Flow_1bckm43" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendConfirmationMail" + }, + "ioMapping": { + "inputs": [ + { + "target": "testVariable", + "source": "=\"123\"" + }, + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ] + }, + "variables": [ + { + "name": "testVariable", + "direction": "INPUT", + "expression": "=\"123\"" + }, + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "=subscriptionId" + } + ] + }, + { + "id": "Activity_ConfirmRegistration", + "type": "receiveTask", + "name": "Confirm subscription", + "incoming": [ + "Flow_1bckm43" + ], + "outgoing": [ + "Flow_1cpwe57" + ], + "boundaryEventRefs": [ + "Timer_EveryDay" + ], + "messageRef": "Message_36dkcng" + }, + { + "id": "Timer_EveryDay", + "type": "boundaryEvent", + "name": "Every day", + "outgoing": [ + "Flow_0x4ewvb" + ], + "attachedToRef": "Activity_ConfirmRegistration", + "cancelActivity": false, + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "PT1M" + } + ] + }, + { + "id": "EndEvent_SubscriptionConfirmed", + "type": "endEvent", + "name": "Subscription confirmed", + "incoming": [ + "Flow_1cpwe57" + ] + } + ], + "sequenceFlows": [ + { + "id": "Flow_05i3x1y", + "sourceRef": "StartEvent_RequestReceived", + "targetRef": "Activity_SendConfirmationMail" + }, + { + "id": "Flow_0x4ewvb", + "sourceRef": "Timer_EveryDay", + "targetRef": "Activity_SendConfirmationMail" + }, + { + "id": "Flow_1bckm43", + "sourceRef": "Activity_SendConfirmationMail", + "targetRef": "Activity_ConfirmRegistration" + }, + { + "id": "Flow_1cpwe57", + "sourceRef": "Activity_ConfirmRegistration", + "targetRef": "EndEvent_SubscriptionConfirmed" + } + ] + }, + { + "id": "ErrorEvent_InvalidMail", + "type": "boundaryEvent", + "name": "Invalid Mail", + "outgoing": [ + "Flow_0i2ctuv" + ], + "attachedToRef": "SubProcess_Confirmation", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "error", + "errorRef": "Error_0uxgmyc" + } + ], + "ioMapping": { + "outputs": [ + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "=subscriptionId" + } + ] + }, + { + "id": "EndEvent_RegistrationNotPossible", + "type": "endEvent", + "name": "Registration not possible", + "incoming": [ + "Flow_0i2ctuv" + ], + "eventDefinitions": [ + { + "type": "signal", + "signalRef": "Signal_14g8ki5" + } + ] + }, + { + "id": "Timer_After3Days", + "type": "boundaryEvent", + "name": "After 3 days", + "outgoing": [ + "Flow_1l1lj4m" + ], + "attachedToRef": "SubProcess_Confirmation", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "=testVariable" + } + ] + }, + { + "id": "CallActivity_AbortRegistration", + "type": "callActivity", + "name": "Abort registration", + "incoming": [ + "Flow_1l1lj4m" + ], + "outgoing": [ + "Flow_1bsb8no" + ], + "calledElement": { + "processId": "abort-registration", + "propagateAllInputVariables": false, + "propagateAllOutputVariables": false + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "=subscriptionId" + } + ] + }, + { + "id": "CompensationEndEvent_RegistrationAborted", + "type": "endEvent", + "name": "Registration aborted", + "incoming": [ + "Flow_1bsb8no" + ], + "eventDefinitions": [ + { + "type": "compensation", + "activityRef": "serviceTask_incrementSubscriptionCounter", + "waitForCompletion": false + } + ] + }, + { + "id": "Gateway_SplitNotifications", + "type": "parallelGateway", + "incoming": [ + "Flow_09cuvzp" + ], + "outgoing": [ + "Flow_16hub0n", + "Flow_1p5t47z" + ] + }, + { + "id": "Activity_NotifyCommunity", + "type": "serviceTask", + "name": "Notify community", + "incoming": [ + "Flow_1p5t47z" + ], + "outgoing": [ + "Flow_1duwy83" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.notifyCommunity" + } + }, + { + "id": "Gateway_JoinNotifications", + "type": "parallelGateway", + "incoming": [ + "Flow_1i7hjid", + "Flow_1duwy83" + ], + "outgoing": [ + "Flow_1862jd8" + ] + }, + { + "id": "EndEvent_RegistrationCompleted", + "type": "endEvent", + "name": "Registration completed", + "incoming": [ + "Flow_1862jd8" + ], + "eventDefinitions": [ + { + "type": "message" + } + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.registrationCompleted" + }, + "ioMapping": { + "outputs": [ + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "=subscriptionId" + } + ] + }, + { + "id": "Activity_SendWelcomeMail", + "type": "serviceTask", + "name": "Send Welcome-Mail", + "incoming": [ + "Flow_16hub0n" + ], + "outgoing": [ + "Flow_1i7hjid" + ], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendWelcomeMail" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ], + "outputs": [ + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "=subscriptionId" + }, + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "=subscriptionId" + } + ] + }, + { + "id": "CompensationTask_DecrementSubscriptionCounter", + "type": "serviceTask", + "name": "Decrement subscription counter", + "isForCompensation": true, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "=subscriptionId" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "=subscriptionId" + } + ] + } + ], + "sequenceFlows": [ + { + "id": "Flow_09cuvzp", + "sourceRef": "SubProcess_Confirmation", + "targetRef": "Gateway_SplitNotifications" + }, + { + "id": "Flow_0i2ctuv", + "sourceRef": "ErrorEvent_InvalidMail", + "targetRef": "EndEvent_RegistrationNotPossible" + }, + { + "id": "Flow_0zdmt0t", + "sourceRef": "serviceTask_incrementSubscriptionCounter", + "targetRef": "SubProcess_Confirmation" + }, + { + "id": "Flow_16hub0n", + "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_SendWelcomeMail" + }, + { + "id": "Flow_1862jd8", + "sourceRef": "Gateway_JoinNotifications", + "targetRef": "EndEvent_RegistrationCompleted" + }, + { + "id": "Flow_1bsb8no", + "sourceRef": "CallActivity_AbortRegistration", + "targetRef": "CompensationEndEvent_RegistrationAborted" + }, + { + "id": "Flow_1csfyyz", + "sourceRef": "StartEvent_SubmitRegistrationForm", + "targetRef": "serviceTask_incrementSubscriptionCounter" + }, + { + "id": "Flow_1duwy83", + "sourceRef": "Activity_NotifyCommunity", + "targetRef": "Gateway_JoinNotifications" + }, + { + "id": "Flow_1i7hjid", + "sourceRef": "Activity_SendWelcomeMail", + "targetRef": "Gateway_JoinNotifications" + }, + { + "id": "Flow_1l1lj4m", + "sourceRef": "Timer_After3Days", + "targetRef": "CallActivity_AbortRegistration" + }, + { + "id": "Flow_1p5t47z", + "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_NotifyCommunity" + } + ] + }, + "definitions": { + "messages": [ + { + "id": "Message_04tc0t0", + "name": "Message_FormSubmitted" + }, + { + "id": "Message_36dkcng", + "name": "Message_SubscriptionConfirmed", + "correlationKey": "=subscriptionId" + } + ], + "signals": [ + { + "id": "Signal_14g8ki5", + "name": "Signal_RegistrationNotPossible" + } + ], + "errors": [ + { + "id": "Error_0uxgmyc", + "name": "Error_InvalidMail", + "errorCode": "500" + } + ] + } +} \ No newline at end of file diff --git a/bpmn-to-code-core/src/test/resources/json/e2e/operaton-subscribe-newsletter.json b/bpmn-to-code-core/src/test/resources/json/e2e/operaton-subscribe-newsletter.json new file mode 100644 index 00000000..a4a3c7f0 --- /dev/null +++ b/bpmn-to-code-core/src/test/resources/json/e2e/operaton-subscribe-newsletter.json @@ -0,0 +1,698 @@ +{ + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "newsletterSubscription", + "engine": "OPERATON", + "flowNodes": [ + { + "id": "StartEvent_SubmitRegistrationForm", + "type": "startEvent", + "name": "Submit newsletter form", + "outgoing": [ + "Flow_1csfyyz" + ], + "eventDefinitions": [ + { + "type": "message", + "messageRef": "Message_04tc0t0" + } + ], + "ioMapping": { + "outputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "operaton:inputOutput", + "children": [ + { + "$type": "operaton:outputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ] + }, + { + "id": "serviceTask_incrementSubscriptionCounter", + "type": "serviceTask", + "name": "Increment subscription counter", + "incoming": [ + "Flow_1csfyyz" + ], + "outgoing": [ + "Flow_0zdmt0t" + ], + "boundaryEventRefs": [ + "CompensationEvent_OnSubscriptionCounter" + ], + "implementation": { + "type": "delegateExpression", + "expression": "counterClass" + } + }, + { + "id": "CompensationEvent_OnSubscriptionCounter", + "type": "boundaryEvent", + "name": "Registration aborted", + "attachedToRef": "serviceTask_incrementSubscriptionCounter", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "compensation", + "waitForCompletion": false + } + ] + }, + { + "id": "SubProcess_Confirmation", + "type": "subProcess", + "name": "Subscription Confirmation", + "incoming": [ + "Flow_0zdmt0t" + ], + "outgoing": [ + "Flow_09cuvzp" + ], + "boundaryEventRefs": [ + "ErrorEvent_InvalidMail", + "Timer_After3Days" + ], + "flowNodes": [ + { + "id": "StartEvent_RequestReceived", + "type": "startEvent", + "name": "Subscription requested", + "outgoing": [ + "Flow_05i3x1y" + ], + "ioMapping": { + "outputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "operaton:inputOutput", + "children": [ + { + "$type": "operaton:outputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ], + "engineAttributes": { + "operaton:asyncBefore": true + } + }, + { + "id": "Activity_SendConfirmationMail", + "type": "serviceTask", + "name": "Send confirmation mail", + "incoming": [ + "Flow_05i3x1y", + "Flow_0x4ewvb" + ], + "outgoing": [ + "Flow_1bckm43" + ], + "implementation": { + "type": "externalTask", + "topic": "newsletter.sendConfirmationMail" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + }, + { + "target": "otherVariable", + "source": "dummy" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + }, + { + "name": "otherVariable", + "direction": "INPUT", + "expression": "dummy" + } + ], + "extensions": [ + { + "$type": "operaton:inputOutput", + "children": [ + { + "$type": "operaton:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + }, + { + "$type": "operaton:inputParameter", + "attributes": { + "name": "otherVariable" + }, + "body": "dummy" + } + ] + } + ], + "engineAttributes": { + "operaton:type": "external" + } + }, + { + "id": "Activity_ConfirmRegistration", + "type": "userTask", + "name": "Confirm subscription", + "incoming": [ + "Flow_1bckm43" + ], + "outgoing": [ + "Flow_1cpwe57" + ], + "boundaryEventRefs": [ + "Timer_EveryDay" + ], + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "operaton:inputOutput", + "children": [ + { + "$type": "operaton:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ], + "engineAttributes": { + "operaton:asyncAfter": true + } + }, + { + "id": "Timer_EveryDay", + "type": "boundaryEvent", + "name": "Every day", + "outgoing": [ + "Flow_0x4ewvb" + ], + "attachedToRef": "Activity_ConfirmRegistration", + "cancelActivity": false, + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "PT1M" + } + ] + }, + { + "id": "EndEvent_SubscriptionConfirmed", + "type": "endEvent", + "name": "Subscription confirmed", + "incoming": [ + "Flow_1cpwe57" + ] + } + ], + "sequenceFlows": [ + { + "id": "Flow_05i3x1y", + "sourceRef": "StartEvent_RequestReceived", + "targetRef": "Activity_SendConfirmationMail" + }, + { + "id": "Flow_0x4ewvb", + "sourceRef": "Timer_EveryDay", + "targetRef": "Activity_SendConfirmationMail" + }, + { + "id": "Flow_1bckm43", + "sourceRef": "Activity_SendConfirmationMail", + "targetRef": "Activity_ConfirmRegistration" + }, + { + "id": "Flow_1cpwe57", + "sourceRef": "Activity_ConfirmRegistration", + "targetRef": "EndEvent_SubscriptionConfirmed" + } + ] + }, + { + "id": "ErrorEvent_InvalidMail", + "type": "boundaryEvent", + "name": "Invalid Mail", + "outgoing": [ + "Flow_0i2ctuv" + ], + "attachedToRef": "SubProcess_Confirmation", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "error", + "errorRef": "Error_0uxgmyc" + } + ] + }, + { + "id": "EndEvent_RegistrationNotPossible", + "type": "endEvent", + "name": "Registration not possible", + "incoming": [ + "Flow_0i2ctuv" + ], + "eventDefinitions": [ + { + "type": "signal", + "signalRef": "Signal_14g8ki5" + } + ], + "engineAttributes": { + "operaton:asyncBefore": true, + "operaton:exclusive": false + } + }, + { + "id": "Timer_After3Days", + "type": "boundaryEvent", + "name": "After 3 days", + "outgoing": [ + "Flow_1l1lj4m" + ], + "attachedToRef": "SubProcess_Confirmation", + "cancelActivity": true, + "eventDefinitions": [ + { + "type": "timer", + "timerType": "DURATION", + "expression": "${testVariable}" + } + ] + }, + { + "id": "CallActivity_AbortRegistration", + "type": "callActivity", + "name": "Abort registration", + "incoming": [ + "Flow_1l1lj4m" + ], + "outgoing": [ + "Flow_1bsb8no" + ], + "calledElement": { + "processId": "abort-registration" + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "subscriptionId" + }, + { + "name": "reasonCode", + "direction": "INPUT", + "expression": "${reasonCode}" + }, + { + "name": "abortResult", + "direction": "OUTPUT", + "expression": "abortResult" + } + ], + "extensions": [ + { + "$type": "operaton:in", + "attributes": { + "source": "subscriptionId", + "target": "childSubscriptionId" + } + }, + { + "$type": "operaton:in", + "attributes": { + "sourceExpression": "${reasonCode}", + "target": "childReasonCode" + } + }, + { + "$type": "operaton:out", + "attributes": { + "source": "childAbortResult", + "target": "abortResult" + } + } + ], + "engineAttributes": { + "operaton:asyncAfter": true, + "operaton:asyncBefore": true + } + }, + { + "id": "CompensationEndEvent_RegistrationAborted", + "type": "endEvent", + "name": "Registration aborted", + "incoming": [ + "Flow_1bsb8no" + ], + "eventDefinitions": [ + { + "type": "compensation", + "activityRef": "serviceTask_incrementSubscriptionCounter", + "waitForCompletion": false + } + ] + }, + { + "id": "Gateway_SplitNotifications", + "type": "parallelGateway", + "incoming": [ + "Flow_09cuvzp" + ], + "outgoing": [ + "Flow_16hub0n", + "Flow_1p5t47z" + ] + }, + { + "id": "Activity_NotifyCommunity", + "type": "serviceTask", + "name": "Notify community", + "incoming": [ + "Flow_1p5t47z" + ], + "outgoing": [ + "Flow_1duwy83" + ], + "implementation": { + "type": "delegateExpression", + "expression": "newsletter.notifyCommunity" + }, + "engineAttributes": { + "operaton:asyncAfter": true, + "operaton:asyncBefore": true, + "operaton:exclusive": false + } + }, + { + "id": "Gateway_JoinNotifications", + "type": "parallelGateway", + "incoming": [ + "Flow_1i7hjid", + "Flow_1duwy83" + ], + "outgoing": [ + "Flow_1862jd8" + ] + }, + { + "id": "EndEvent_RegistrationCompleted", + "type": "endEvent", + "name": "Registration completed", + "incoming": [ + "Flow_1862jd8" + ], + "eventDefinitions": [ + { + "type": "message" + } + ], + "implementation": { + "type": "externalTask", + "topic": "newsletter.registrationCompleted" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "operaton:inputOutput", + "children": [ + { + "$type": "operaton:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ] + }, + { + "id": "Activity_SendWelcomeMail", + "type": "serviceTask", + "name": "Send Welcome-Mail", + "incoming": [ + "Flow_16hub0n" + ], + "outgoing": [ + "Flow_1i7hjid" + ], + "implementation": { + "type": "delegateExpression", + "expression": "newsletter.sendWelcomeMail" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ], + "outputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + }, + { + "name": "subscriptionId", + "direction": "OUTPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "operaton:inputOutput", + "children": [ + { + "$type": "operaton:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + }, + { + "$type": "operaton:outputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ], + "engineAttributes": { + "operaton:asyncAfter": true, + "operaton:asyncBefore": true, + "operaton:exclusive": false + } + }, + { + "id": "CompensationTask_DecrementSubscriptionCounter", + "type": "serviceTask", + "name": "Decrement subscription counter", + "isForCompensation": true, + "implementation": { + "type": "delegateExpression", + "expression": "counterClass" + }, + "ioMapping": { + "inputs": [ + { + "target": "subscriptionId", + "source": "${subscriptionId}" + } + ] + }, + "variables": [ + { + "name": "subscriptionId", + "direction": "INPUT", + "expression": "${subscriptionId}" + } + ], + "extensions": [ + { + "$type": "operaton:inputOutput", + "children": [ + { + "$type": "operaton:inputParameter", + "attributes": { + "name": "subscriptionId" + }, + "body": "${subscriptionId}" + } + ] + } + ] + } + ], + "sequenceFlows": [ + { + "id": "Flow_09cuvzp", + "sourceRef": "SubProcess_Confirmation", + "targetRef": "Gateway_SplitNotifications" + }, + { + "id": "Flow_0i2ctuv", + "sourceRef": "ErrorEvent_InvalidMail", + "targetRef": "EndEvent_RegistrationNotPossible" + }, + { + "id": "Flow_0zdmt0t", + "sourceRef": "serviceTask_incrementSubscriptionCounter", + "targetRef": "SubProcess_Confirmation" + }, + { + "id": "Flow_16hub0n", + "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_SendWelcomeMail" + }, + { + "id": "Flow_1862jd8", + "sourceRef": "Gateway_JoinNotifications", + "targetRef": "EndEvent_RegistrationCompleted" + }, + { + "id": "Flow_1bsb8no", + "sourceRef": "CallActivity_AbortRegistration", + "targetRef": "CompensationEndEvent_RegistrationAborted" + }, + { + "id": "Flow_1csfyyz", + "sourceRef": "StartEvent_SubmitRegistrationForm", + "targetRef": "serviceTask_incrementSubscriptionCounter" + }, + { + "id": "Flow_1duwy83", + "sourceRef": "Activity_NotifyCommunity", + "targetRef": "Gateway_JoinNotifications" + }, + { + "id": "Flow_1i7hjid", + "sourceRef": "Activity_SendWelcomeMail", + "targetRef": "Gateway_JoinNotifications" + }, + { + "id": "Flow_1l1lj4m", + "sourceRef": "Timer_After3Days", + "targetRef": "CallActivity_AbortRegistration" + }, + { + "id": "Flow_1p5t47z", + "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_NotifyCommunity" + } + ] + }, + "definitions": { + "messages": [ + { + "id": "Message_04tc0t0", + "name": "Message_FormSubmitted" + }, + { + "id": "Message_36dkcng", + "name": "Message_SubscriptionConfirmed" + } + ], + "signals": [ + { + "id": "Signal_14g8ki5", + "name": "Signal_RegistrationNotPossible" + } + ], + "errors": [ + { + "id": "Error_0uxgmyc", + "name": "Error_InvalidMail", + "errorCode": "500" + } + ] + } +} \ No newline at end of file diff --git a/bpmn-to-code-gradle/src/main/kotlin/io/miragon/bpmn/adapter/BpmnModelGeneratorPlugin.kt b/bpmn-to-code-gradle/src/main/kotlin/io/miragon/bpmn/adapter/BpmnModelGeneratorPlugin.kt index b483a568..25ab2573 100644 --- a/bpmn-to-code-gradle/src/main/kotlin/io/miragon/bpmn/adapter/BpmnModelGeneratorPlugin.kt +++ b/bpmn-to-code-gradle/src/main/kotlin/io/miragon/bpmn/adapter/BpmnModelGeneratorPlugin.kt @@ -1,9 +1,9 @@ package io.miragon.bpmn.adapter +import java.util.Properties import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project -import java.util.Properties @Suppress("unused") class BpmnModelGeneratorPlugin : Plugin { diff --git a/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradlePluginDependencyResolutionSmokeTest.kt b/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradlePluginDependencyResolutionSmokeTest.kt index e506fffa..542ce491 100644 --- a/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradlePluginDependencyResolutionSmokeTest.kt +++ b/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradlePluginDependencyResolutionSmokeTest.kt @@ -1,11 +1,11 @@ package io.miragon.bpmn.adapter +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir -import java.io.File /** * Resolves the plugin from mavenLocal (published POM) instead of using withPluginClasspath(). diff --git a/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradlePluginSmokeTest.kt b/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradlePluginSmokeTest.kt index 32985b5f..de783a8b 100644 --- a/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradlePluginSmokeTest.kt +++ b/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradlePluginSmokeTest.kt @@ -1,12 +1,12 @@ package io.miragon.bpmn.adapter +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource -import java.io.File class GradlePluginSmokeTest { diff --git a/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradleValidationSmokeTest.kt b/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradleValidationSmokeTest.kt index a3b622e0..6c135b95 100644 --- a/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradleValidationSmokeTest.kt +++ b/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/GradleValidationSmokeTest.kt @@ -1,12 +1,12 @@ package io.miragon.bpmn.adapter +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource -import java.io.File class GradleValidationSmokeTest { diff --git a/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/MultiModuleRuntimeSharingSmokeTest.kt b/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/MultiModuleRuntimeSharingSmokeTest.kt index 03b9611b..5f865e89 100644 --- a/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/MultiModuleRuntimeSharingSmokeTest.kt +++ b/bpmn-to-code-gradle/src/test/kotlin/io/miragon/bpmn/adapter/MultiModuleRuntimeSharingSmokeTest.kt @@ -1,11 +1,11 @@ package io.miragon.bpmn.adapter +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir -import java.io.File /** * Verifies the multi-module promise: a `common` module can expose typed wrappers over the runtime's diff --git a/bpmn-to-code-maven/src/test/kotlin/io/miragon/bpmn/adapter/MavenMojoSmokeTest.kt b/bpmn-to-code-maven/src/test/kotlin/io/miragon/bpmn/adapter/MavenMojoSmokeTest.kt index 403dd0dd..bb87dd72 100644 --- a/bpmn-to-code-maven/src/test/kotlin/io/miragon/bpmn/adapter/MavenMojoSmokeTest.kt +++ b/bpmn-to-code-maven/src/test/kotlin/io/miragon/bpmn/adapter/MavenMojoSmokeTest.kt @@ -1,10 +1,10 @@ package io.miragon.bpmn.adapter +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource -import java.io.File class MavenMojoSmokeTest { diff --git a/bpmn-to-code-maven/src/test/kotlin/io/miragon/bpmn/adapter/MavenValidateMojoSmokeTest.kt b/bpmn-to-code-maven/src/test/kotlin/io/miragon/bpmn/adapter/MavenValidateMojoSmokeTest.kt index 8bd49589..0e4d0a30 100644 --- a/bpmn-to-code-maven/src/test/kotlin/io/miragon/bpmn/adapter/MavenValidateMojoSmokeTest.kt +++ b/bpmn-to-code-maven/src/test/kotlin/io/miragon/bpmn/adapter/MavenValidateMojoSmokeTest.kt @@ -1,10 +1,10 @@ package io.miragon.bpmn.adapter +import java.io.File import org.assertj.core.api.Assertions.assertThatCode import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource -import java.io.File class MavenValidateMojoSmokeTest { diff --git a/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnResourceLoader.kt b/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnResourceLoader.kt index 1c28b349..b45761e5 100644 --- a/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnResourceLoader.kt +++ b/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnResourceLoader.kt @@ -2,8 +2,8 @@ package io.miragon.bpmn.testing import io.miragon.bpmn.domain.BpmnResource import java.net.URL -import java.nio.file.FileSystems import java.nio.file.FileSystemNotFoundException +import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.extension diff --git a/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnRules.kt b/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnRules.kt index bd245811..cfe4b3a1 100644 --- a/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnRules.kt +++ b/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnRules.kt @@ -18,6 +18,7 @@ import io.miragon.bpmn.domain.validation.rules.TimerIso8601SyntaxRule import io.miragon.bpmn.domain.validation.rules.UncaughtMessageThrowRule import io.miragon.bpmn.domain.validation.rules.UncaughtSignalThrowRule import io.miragon.bpmn.domain.validation.rules.UnpublishedSignalCatchRule +import io.miragon.bpmn.domain.validation.rules.UnreferencedRootElementRule /** * Provides access to all built-in BPMN validation rules. @@ -55,6 +56,13 @@ object BpmnRules { @JvmField val MISSING_SIGNAL_NAME: SingleModelValidationRule = MissingSignalNameRule() + /** + * A message, signal, error or escalation that nothing references is usually left over from an + * earlier version of the model. It still produces a constant in the generated API. + */ + @JvmField + val UNREFERENCED_ROOT_ELEMENT: SingleModelValidationRule = UnreferencedRootElementRule() + /** * Timers without a valid type (Date/Duration/Cycle) are a deployment-time error on most engines. */ @@ -162,6 +170,7 @@ object BpmnRules { MISSING_MESSAGE_NAME, MISSING_ERROR_DEFINITION, MISSING_SIGNAL_NAME, + UNREFERENCED_ROOT_ELEMENT, MISSING_TIMER_DEFINITION, MISSING_CALLED_ELEMENT, MISSING_ELEMENT_ID, diff --git a/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnValidationAssert.kt b/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnValidationAssert.kt index 24fa20a4..41abcd21 100644 --- a/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnValidationAssert.kt +++ b/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnValidationAssert.kt @@ -1,7 +1,7 @@ package io.miragon.bpmn.testing -import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.ValidationResult +import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.ValidationViolation import org.assertj.core.api.AbstractAssert diff --git a/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnValidator.kt b/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnValidator.kt index 5fe59290..3bbf3425 100644 --- a/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnValidator.kt +++ b/bpmn-to-code-testing/src/main/kotlin/io/miragon/bpmn/testing/BpmnValidator.kt @@ -1,18 +1,18 @@ package io.miragon.bpmn.testing -import io.miragon.bpmn.adapter.outbound.engine.ExtractBpmnAdapter -import io.miragon.bpmn.domain.BpmnModel +import io.miragon.bpmn.adapter.inbound.ExtractProcessModelsPlugin import io.miragon.bpmn.domain.BpmnResource +import io.miragon.bpmn.domain.ProcessModel import io.miragon.bpmn.domain.service.ModelMergerService import io.miragon.bpmn.domain.shared.ProcessEngine import io.miragon.bpmn.domain.validation.CrossModelValidationRule import io.miragon.bpmn.domain.validation.SingleModelValidationRule +import io.miragon.bpmn.domain.validation.ValidationResult import io.miragon.bpmn.domain.validation.ValidationRule import io.miragon.bpmn.domain.validation.model.CrossModelValidationContext import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import io.miragon.bpmn.domain.validation.model.ValidationPhase -import io.miragon.bpmn.domain.validation.ValidationResult import io.miragon.bpmn.domain.validation.model.ValidationViolation import java.nio.file.Path @@ -87,9 +87,7 @@ class BpmnValidator private constructor( "Process engine must be set. Call .engine(ProcessEngine.CAMUNDA_7) or similar before .validate()" } - val extractor = ExtractBpmnAdapter() - val resources = resourceLoader() - val models = resources.map { extractor.extract(it, selectedEngine) } + val models = ExtractProcessModelsPlugin().execute(resourceLoader(), selectedEngine) val activeRules = resolveRules() val result = runValidation(models, selectedEngine, activeRules) return BpmnValidationAssert.assertThat(result) @@ -109,7 +107,7 @@ class BpmnValidator private constructor( } private fun runValidation( - models: List, + models: List, engine: ProcessEngine, activeRules: List, ): ValidationResult { diff --git a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnResourceLoaderTest.kt b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnResourceLoaderTest.kt index 33bef93e..8978eced 100644 --- a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnResourceLoaderTest.kt +++ b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnResourceLoaderTest.kt @@ -1,15 +1,15 @@ package io.miragon.bpmn.testing -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir import java.io.FileOutputStream import java.net.URLClassLoader import java.nio.file.Files import java.nio.file.Path import java.util.jar.JarEntry import java.util.jar.JarOutputStream +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir class BpmnResourceLoaderTest { diff --git a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnRulesTest.kt b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnRulesTest.kt index e7cf6b12..0ae78d2e 100644 --- a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnRulesTest.kt +++ b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnRulesTest.kt @@ -6,9 +6,9 @@ import org.junit.jupiter.api.Test class BpmnRulesTest { @Test - fun `all() returns all 10 built-in rules`() { + fun `all() returns all 11 built-in rules`() { val rules = BpmnRules.all() - assertThat(rules).hasSize(10) + assertThat(rules).hasSize(11) } @Test diff --git a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnValidationAssertTest.kt b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnValidationAssertTest.kt index 74d8996c..bc2d4346 100644 --- a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnValidationAssertTest.kt +++ b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnValidationAssertTest.kt @@ -1,7 +1,7 @@ package io.miragon.bpmn.testing -import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.ValidationResult +import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.ValidationViolation import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatCode diff --git a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnValidatorTest.kt b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnValidatorTest.kt index b39d5cf8..d8ba40ee 100644 --- a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnValidatorTest.kt +++ b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/BpmnValidatorTest.kt @@ -5,11 +5,11 @@ import io.miragon.bpmn.domain.validation.SingleModelValidationRule import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext import io.miragon.bpmn.domain.validation.model.ValidationViolation +import java.nio.file.Files +import java.nio.file.Path import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir -import java.nio.file.Files -import java.nio.file.Path class BpmnValidatorTest { diff --git a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/SingleModelRuleTest.kt b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/SingleModelRuleTest.kt index 8f6a3e6f..6fdfcf6a 100644 --- a/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/SingleModelRuleTest.kt +++ b/bpmn-to-code-testing/src/test/kotlin/io/miragon/bpmn/testing/SingleModelRuleTest.kt @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test /** * Covers the single-model rule extension point: a custom [SingleModelValidationRule] can inspect the - * parts of a single [io.miragon.bpmn.domain.BpmnModel]. Grouped by which part of the model the rule reaches. + * parts of a single [io.miragon.bpmn.domain.ProcessModel]. Grouped by which part of the model the rule reaches. */ class SingleModelRuleTest { @@ -125,7 +125,9 @@ class SingleModelRuleTest { } } - /** Allows ${null}, ${true}, ${false} and ${execution.getVariable('...')} as output expressions. */ + /** + * Allows ${null}, ${true}, ${false} and ${execution.getVariable('...')} as output expressions. + */ private class OutputExpressionAllowListRule : SingleModelValidationRule { override val id = "output-expression-allow-list" override val severity = Severity.ERROR diff --git a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/Application.kt b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/Application.kt index af81ac1a..faf6eebb 100644 --- a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/Application.kt +++ b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/Application.kt @@ -2,14 +2,9 @@ package io.miragon.bpmn.web -import io.miragon.bpmn.web.config.AppConfig -import io.miragon.bpmn.web.model.ConfigResponse import io.github.oshai.kotlinlogging.KotlinLogging -import io.miragon.bpmn.web.routes.generateJsonRoutes -import io.miragon.bpmn.web.routes.generateRoutes -import io.miragon.bpmn.web.service.WebGenerationService -import io.miragon.bpmn.web.service.WebJsonGenerationService import io.ktor.http.* +import io.ktor.openapi.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* @@ -19,12 +14,17 @@ import io.ktor.server.plugins.calllogging.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* import io.ktor.server.plugins.statuspages.* -import io.ktor.openapi.* import io.ktor.server.plugins.swagger.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.routing.openapi.* import io.ktor.utils.io.ExperimentalKtorApi +import io.miragon.bpmn.web.config.AppConfig +import io.miragon.bpmn.web.model.ConfigResponse +import io.miragon.bpmn.web.routes.generateJsonRoutes +import io.miragon.bpmn.web.routes.generateRoutes +import io.miragon.bpmn.web.service.WebGenerationService +import io.miragon.bpmn.web.service.WebJsonGenerationService import kotlinx.serialization.json.Json private val logger = KotlinLogging.logger {} diff --git a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/model/GenerateJsonResponse.kt b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/model/GenerateJsonResponse.kt index 80064c0e..268bd98d 100644 --- a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/model/GenerateJsonResponse.kt +++ b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/model/GenerateJsonResponse.kt @@ -1,7 +1,7 @@ package io.miragon.bpmn.web.model -import io.miragon.bpmn.domain.validation.BpmnValidationException import io.ktor.http.* +import io.miragon.bpmn.domain.validation.BpmnValidationException import kotlinx.serialization.Serializable import kotlinx.serialization.Transient diff --git a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/model/GenerateResponse.kt b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/model/GenerateResponse.kt index 909636f8..503aeb3b 100644 --- a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/model/GenerateResponse.kt +++ b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/model/GenerateResponse.kt @@ -1,7 +1,7 @@ package io.miragon.bpmn.web.model -import io.miragon.bpmn.domain.validation.BpmnValidationException import io.ktor.http.* +import io.miragon.bpmn.domain.validation.BpmnValidationException import kotlinx.serialization.Serializable import kotlinx.serialization.Transient diff --git a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/routes/GenerateJsonRoutes.kt b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/routes/GenerateJsonRoutes.kt index 50f487ff..bac44360 100644 --- a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/routes/GenerateJsonRoutes.kt +++ b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/routes/GenerateJsonRoutes.kt @@ -2,15 +2,15 @@ package io.miragon.bpmn.web.routes -import io.miragon.bpmn.web.model.GenerateJsonRequest -import io.miragon.bpmn.web.model.GenerateJsonResponse -import io.miragon.bpmn.web.service.WebJsonGenerationService import io.ktor.http.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.routing.openapi.* import io.ktor.utils.io.ExperimentalKtorApi +import io.miragon.bpmn.web.model.GenerateJsonRequest +import io.miragon.bpmn.web.model.GenerateJsonResponse +import io.miragon.bpmn.web.service.WebJsonGenerationService fun Route.generateJsonRoutes( jsonService: WebJsonGenerationService, diff --git a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/routes/GenerateRoutes.kt b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/routes/GenerateRoutes.kt index 2242bdce..8505ead8 100644 --- a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/routes/GenerateRoutes.kt +++ b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/routes/GenerateRoutes.kt @@ -2,15 +2,15 @@ package io.miragon.bpmn.web.routes -import io.miragon.bpmn.web.model.GenerateRequest -import io.miragon.bpmn.web.model.GenerateResponse -import io.miragon.bpmn.web.service.WebGenerationService import io.ktor.http.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.routing.openapi.* import io.ktor.utils.io.ExperimentalKtorApi +import io.miragon.bpmn.web.model.GenerateRequest +import io.miragon.bpmn.web.model.GenerateResponse +import io.miragon.bpmn.web.service.WebGenerationService fun Route.generateRoutes( generationService: WebGenerationService diff --git a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/service/WebGenerationService.kt b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/service/WebGenerationService.kt index fb979245..7557431d 100644 --- a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/service/WebGenerationService.kt +++ b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/service/WebGenerationService.kt @@ -1,7 +1,7 @@ package io.miragon.bpmn.web.service -import io.miragon.bpmn.adapter.inbound.CreateProcessApiInMemoryPlugin import io.github.oshai.kotlinlogging.KotlinLogging +import io.miragon.bpmn.adapter.inbound.CreateProcessApiInMemoryPlugin import io.miragon.bpmn.domain.GeneratedApiFile import io.miragon.bpmn.domain.validation.BpmnValidationException import io.miragon.bpmn.web.model.GenerateRequest diff --git a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/service/WebJsonGenerationService.kt b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/service/WebJsonGenerationService.kt index e449c75f..1c5eacef 100644 --- a/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/service/WebJsonGenerationService.kt +++ b/bpmn-to-code-web/src/main/kotlin/io/miragon/bpmn/web/service/WebJsonGenerationService.kt @@ -1,11 +1,11 @@ package io.miragon.bpmn.web.service +import io.github.oshai.kotlinlogging.KotlinLogging import io.miragon.bpmn.adapter.inbound.CreateProcessJsonInMemoryPlugin import io.miragon.bpmn.domain.GeneratedJsonFile import io.miragon.bpmn.domain.validation.BpmnValidationException import io.miragon.bpmn.web.model.GenerateJsonRequest import io.miragon.bpmn.web.model.GenerateJsonResponse -import io.github.oshai.kotlinlogging.KotlinLogging import java.util.Base64 class WebJsonGenerationService { diff --git a/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/model/GenerateJsonResponseTest.kt b/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/model/GenerateJsonResponseTest.kt index aeab272e..afc3e9b7 100644 --- a/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/model/GenerateJsonResponseTest.kt +++ b/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/model/GenerateJsonResponseTest.kt @@ -1,9 +1,9 @@ package io.miragon.bpmn.web.model +import io.ktor.http.* import io.miragon.bpmn.domain.validation.BpmnValidationException import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.ValidationViolation -import io.ktor.http.* import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test diff --git a/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/model/GenerateResponseTest.kt b/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/model/GenerateResponseTest.kt index 94eda6f9..9619b692 100644 --- a/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/model/GenerateResponseTest.kt +++ b/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/model/GenerateResponseTest.kt @@ -1,9 +1,9 @@ package io.miragon.bpmn.web.model +import io.ktor.http.* import io.miragon.bpmn.domain.validation.BpmnValidationException import io.miragon.bpmn.domain.validation.model.Severity import io.miragon.bpmn.domain.validation.model.ValidationViolation -import io.ktor.http.* import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test diff --git a/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/service/WebGenerationServiceTest.kt b/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/service/WebGenerationServiceTest.kt index 2b4b0def..3a13281d 100644 --- a/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/service/WebGenerationServiceTest.kt +++ b/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/service/WebGenerationServiceTest.kt @@ -3,9 +3,9 @@ package io.miragon.bpmn.web.service import io.miragon.bpmn.domain.shared.OutputLanguage import io.miragon.bpmn.domain.shared.ProcessEngine import io.miragon.bpmn.web.model.GenerateRequest +import java.util.Base64 import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import java.util.Base64 class WebGenerationServiceTest { diff --git a/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/service/WebJsonGenerationServiceTest.kt b/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/service/WebJsonGenerationServiceTest.kt index abab48b1..5a0f4c80 100644 --- a/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/service/WebJsonGenerationServiceTest.kt +++ b/bpmn-to-code-web/src/test/kotlin/io/miragon/bpmn/web/service/WebJsonGenerationServiceTest.kt @@ -2,9 +2,9 @@ package io.miragon.bpmn.web.service import io.miragon.bpmn.domain.shared.ProcessEngine import io.miragon.bpmn.web.model.GenerateJsonRequest +import java.util.Base64 import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import java.util.Base64 class WebJsonGenerationServiceTest { diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d5c8ab63..5e4c36aa 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -101,6 +101,7 @@ export default defineConfig({ collapsed: true, items: [ { text: 'Release Notes', link: '/changelog/' }, + { text: 'v6 Migration Guide', link: '/changelog/v6' }, { text: 'v5 Migration Guide', link: '/changelog/v5' }, { text: 'v4 Migration Guide', link: '/changelog/v4' }, { text: 'v3 Migration Guide', link: '/changelog/v3' }, diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 232544a0..4d1b2f35 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -3,6 +3,7 @@ Release notes for bpmn-to-code. New entries are added automatically by [release-please](https://github.com/googleapis/release-please) when a release PR is merged. +- Upgrading to v6 (the generated JSON follows the BPMN standard; the code API is unchanged)? See the [v6 Migration Guide](./v6). - Upgrading to v5 (deprecated `BpmnValidationRule` / `ValidationContext` aliases removed)? See the [v5 Migration Guide](./v5). - Upgrading to v4 (deprecated `io.github.emaarco` runtime types removed)? See the [v4 Migration Guide](./v4). - Moving to v3 (the `io.miragon` namespace)? See the [v3 Migration Guide](./v3). diff --git a/docs/changelog/v6.md b/docs/changelog/v6.md new file mode 100644 index 00000000..9aca1762 --- /dev/null +++ b/docs/changelog/v6.md @@ -0,0 +1,314 @@ +# v6.0.0 Migration Guide — BPMN-aligned process JSON + +**v6.0.0 replaces the generated JSON format.** The generated Kotlin/Java Process API is unchanged, with +[one documented exception](#unreferenced-root-elements-now-surface) — if you only use the code API, +upgrading is close to a version bump. + +## Am I affected? + +Only if you **read the generated `.json` files** — in an AI workflow, a CI check, a dashboard, or any +custom tooling. Everything else is unaffected: + +| Surface | Affected | +|---------|----------| +| Generated Kotlin/Java Process API | almost — see *unreferenced root elements* below | +| Built-in rules, the `BpmnValidator` API and assertions | no | +| Gradle / Maven task configuration | no | +| Generated JSON | **yes — new format** | +| **Custom** validation rules that inspect the model type | **yes — see below** | + +## Why the format changed + +The JSON export was still marked *beta*, and locking it down as a stable contract meant fixing what could +not be expressed in it first ([#58](https://github.com/Miragon/bpmn-to-code/issues/58)): + +- A node carried a single `properties` slot holding exactly one facet. A multi-instance service task with + an I/O mapping was **not representable** — which is why + [#73](https://github.com/Miragon/bpmn-to-code/issues/73) and + [#74](https://github.com/Miragon/bpmn-to-code/issues/74) could not ship against the old shape. +- Only one event definition survived per event, though BPMN allows several. +- `messages` / `signals` / `errors` were keyed by the *event's* ID, not the `bpmn:Message` root element, + so one message used by three events appeared three times and could not be resolved by reference. +- `engineSpecificProperties` could hold primitives only, so `zeebe:taskHeaders` or connector + configuration were silently flattened to strings. +- Sequence flows had no scope, so a flow inside a sub-process was indistinguishable from a top-level one. + +The new format follows OMG BPMN 2.0 and the [`bpmn-moddle`](https://github.com/bpmn-io/bpmn-moddle) +vocabulary, so it stays stable as BPMN features are added. See +[ADR 018](https://github.com/Miragon/bpmn-to-code/blob/main/docs/contributing/adr/018-process-json-v2.md). + +## Detecting the version + +Every file now declares its schema and format version: + +```json +{ + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0" +} +``` + +Consumers that must handle both can branch on `formatVersion` — the old format has no such field. + +## What changed + +### The process moved under `process` + +```json +// Before +{ "processId": "newsletterSubscription", "flowNodes": [], "sequenceFlows": [] } + +// After +{ "process": { "id": "newsletterSubscription", "flowNodes": [], "sequenceFlows": [] } } +``` + +`process` also gained `name`, `isExecutable` and `engine`, which the old format discarded. + +### `elementType` → `type` + `eventDefinitions` + +The flattened `_` vocabulary is gone. `type` is now the BPMN element name, and the event's +triggers are a list — so an event with two triggers no longer loses one. + +```json +// Before +{ "elementType": "MESSAGE_START_EVENT" } + +// After +{ + "type": "startEvent", + "eventDefinitions": [{ "type": "message", "messageRef": "Message_FormSubmitted" }] +} +``` + +To find message start events: + +```js +// Before +nodes.filter(n => n.elementType === "MESSAGE_START_EVENT") + +// After +nodes.filter(n => n.type === "startEvent" + && n.eventDefinitions?.some(d => d.type === "message")) +``` + +### Relations point at sequence flows, not nodes + +`incoming` / `outgoing` now hold **sequence-flow IDs**, matching `bpmn:FlowNode.incoming` / `.outgoing`. +The old node-to-node adjacency lost *which* flow was taken, so conditions and default branches could not +be attributed. + +```js +// Before — outgoing held node ids +const nextNodeIds = node.outgoing + +// After — resolve through the flow +const nextNodeIds = node.outgoing + .map(id => scope.sequenceFlows.find(f => f.id === id)) + .map(flow => flow.targetRef) +``` + +`isDefault` on the flow became `default` on the gateway or activity that owns it, as BPMN defines it. + +### Nesting is real containment + +`parentId` and `attachedElements` are gone. A sub-process owns its children **and its own sequence +flows**; an activity lists its boundary events in `boundaryEventRefs`. + +```js +// Before — reconstruct nesting from a flat list +const children = nodes.filter(n => n.parentId === subProcessId) + +// After +const children = subProcess.flowNodes +``` + +Walking every node, at any depth: + +```js +function allNodes(scope) { + return (scope.flowNodes ?? []).flatMap(n => [n, ...allNodes(n)]) +} +``` + +### Root elements are a resolvable registry + +`messages`, `signals`, `errors` and `escalations` moved into `definitions` and are keyed by their **own** +`bpmn:Definitions` ID. Nodes point at them via `messageRef` / `signalRef` / `errorRef` / `escalationRef`. + +```json +// Before — keyed by the event node, duplicated per usage +"messages": [{ "id": "StartEvent_SubmitRegistrationForm", "name": "Message_FormSubmitted" }] + +// After — one entry, referenced from every event that uses it +"definitions": { + "messages": [{ "id": "Message_FormSubmitted", "name": "Message_FormSubmitted" }] +} +``` + +`errors[].code` is now `errorCode`. The Zeebe `zeebe:subscription` correlation key moved from the event to +`definitions.messages[].correlationKey`, because BPMN declares it on the message element itself. + +The top-level `compensations` list is gone; a compensation is an event definition on its node, and its +`activityRef` now points at the compensated activity — previously it repeated the event's own ID, which +was a bug. + +### `properties` split into typed facets + +```json +// Before +"properties": { "type": "ServiceTask", "implementationValue": "newsletter.sendWelcomeMail" } + +// After +"implementation": { "type": "jobWorker", "jobType": "newsletter.sendWelcomeMail" } +``` + +The one-of slot became independent optional facets — `implementation`, `ioMapping`, `multiInstance`, +`calledElement`, `variables` — each present only where BPMN allows it. This is what makes #73 and #74 +representable. + +### Variables carry direction and expression + +```json +// Before +"variables": ["subscriptionId"] + +// After +"variables": [{ "name": "subscriptionId", "direction": "OUTPUT", "expression": "=subscriptionId" }] +``` + +This brings the JSON in line with the code API, which has split `Inputs` / `Outputs` since +[ADR 015](https://github.com/Miragon/bpmn-to-code/blob/main/docs/contributing/adr/015-directional-variable-extraction.md). + +### Engine data is namespaced and structured + +```json +// Before — flat, primitives only, no provenance +"engineSpecificProperties": { "asyncBefore": true } + +// After +"engineAttributes": { "camunda:asyncBefore": true }, +"extensions": [ + { + "$type": "zeebe:taskHeaders", + "children": [ + { "$type": "zeebe:header", "attributes": { "key": "priority", "value": "high" } } + ] + } +] +``` + +`extensions` mirrors `bpmn:extensionElements` and nests arbitrarily, so structured engine configuration +survives intact. Note that `engineAttributes` reports **every** foreign-namespace attribute verbatim, so +Camunda defaults such as `camunda:exclusive="true"` now appear where the old format omitted them. + +## Field reference + +| Before | After | +|--------|-------| +| `processId` | `process.id` | +| `flowNodes` | `process.flowNodes` | +| `displayName` | `name` | +| `elementType` | `type` + `eventDefinitions[]` | +| `incoming` / `outgoing` (node IDs) | `incoming` / `outgoing` (sequence-flow IDs) | +| `parentId` | containment — `flowNodes` on the parent scope | +| `attachedElements` | `boundaryEventRefs` | +| `interrupting` | `cancelActivity` (boundary) / `isInterrupting` (event sub-process start) | +| `properties.implementationValue` | `implementation.*` | +| `properties.engineSpecificProperties.correlationKey` | `definitions.messages[].correlationKey` | +| `engineSpecificProperties` | `engineAttributes` + `extensions` | +| `variables: ["x"]` | `variables: [{ name, direction, expression }]` | +| `messages` / `signals` / `errors` | `definitions.*`, keyed by root-element ID | +| `errors[].code` | `definitions.errors[].errorCode` | +| `compensations` | `eventDefinitions[{ "type": "compensation" }]` on the node | +| `sequenceFlows[].isDefault` | `default` on the source gateway or activity | +| — | `$schema`, `formatVersion` | +| — | `process.name`, `process.isExecutable`, `process.engine` | +| — | `multiInstance` ([#73](https://github.com/Miragon/bpmn-to-code/issues/73)) | +| — | `ioMapping` ([#74](https://github.com/Miragon/bpmn-to-code/issues/74)) | + +## Validating your consumer + +The schema is published and closed, so you can check your assumptions against it rather than against a +sample file: + +```bash +npx ajv-cli validate \ + -s https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json \ + -d "src/main/resources/bpmn-json/*.json" \ + --spec=draft2020 +``` + +## Custom validation rules: one model type instead of three + +`BpmnModel` and `MergedBpmnModel` are gone. There is now a single `ProcessModel` data class, and whether a +process was merged from several BPMN files is a property rather than a type: + +```kotlin +// Before +val detected = (context.model as? BpmnModel)?.detectedEngine +if (context.model is MergedBpmnModel) { /* ... */ } + +// After +val detected = context.model.detectedEngine +if (context.model.isMerged) { /* ... */ } +``` + +Rules that only read `context.model.processId`, `.flowNodes`, `.allFlowNodes` or the derived projections +need no change. + +The four root-element registries moved behind `definitions`, matching the JSON: + +```kotlin +// Before +context.model.messages +context.model.signals + +// After +context.model.definitions.messages +context.model.definitions.signals +``` + +`ProcessModel.engine` is now `ProcessModel.detectedEngine` — the engine read from the file's namespaces. +The engine that code is *generated for* lives on `BpmnModelApi.targetEngine`. The two were previously both +called `engine`, which made the distinction easy to get wrong. + +## Unreferenced root elements now surface + +A BPMN file may declare a `bpmn:Message`, `bpmn:Signal`, `bpmn:Error` or `bpmn:Escalation` that no element +references — usually left behind when the event that used it was deleted. Earlier versions silently dropped +those declarations. They are now kept, because the model mirrors the file, and a new **`unreferenced-root-element`** +rule (severity `WARN`, part of `BpmnRules.all()`) reports them: + +``` +WARN unreferenced-root-element Message 'Message_36dkcng' is declared but no element references it. + It still produces a constant in the generated API — remove it from the BPMN file if it is left + over from an earlier version of the model. +``` + +**What this means for you:** if one of your models declares such an element, its generated API gains one +constant. Nothing is removed and no existing constant changes. To get the old output back, delete the +declaration from the BPMN file — which is what the warning is asking you to do. + +Across this project's own fixtures the effect was 4 of 16 generated files, each gaining a single constant. + +## Also in 6.0.0 + +Two behaviour changes outside the JSON format, both fixing under-reporting in validation: + +- **Every unimplemented service task is now reported.** Service tasks were deduplicated by their + implementation reference — empty for an unconfigured task — so N service tasks without an + implementation produced a single violation naming one element. Expect more violations from + `missing-service-task-implementation` if your model has several. +- **A merged process reports its real `isExecutable`.** Merged models hardcoded `true`. Only the + API-generation path filters non-executable processes, and it does so before merging, so a process whose + variants were all marked `isExecutable="false"` was published as executable in the JSON. +- **Root elements sharing a name are kept apart.** Two `bpmn:Message` elements with the same name and + distinct IDs — what a modeller gets by typing the same name twice — previously collapsed into one, + leaving the other node's reference dangling. Both are kept now; the generated API still emits one + constant per name. + +Terminate, conditional and link events also report a precise `elementType` in the *code* API +(`TERMINATE_END_EVENT` instead of `END_EVENT`), and send tasks now count as message throwers in +`UncaughtMessageThrowRule`. + +See the full [release notes](./). diff --git a/docs/contributing/adr/004-strategy-pattern-multi-engine.md b/docs/contributing/adr/004-strategy-pattern-multi-engine.md index 091fceb8..ff1c9827 100644 --- a/docs/contributing/adr/004-strategy-pattern-multi-engine.md +++ b/docs/contributing/adr/004-strategy-pattern-multi-engine.md @@ -18,7 +18,7 @@ Each extractor implements `EngineSpecificExtractor` interface and handles its en ## Consequences ### Positive -- **Extensibility**: New engines added by implementing `EngineSpecificExtractor` +- **Extensibility**: New engines added without touching the existing ones - **Separation**: Engine-specific logic isolated in dedicated classes - **Maintainability**: Changes to one engine don't affect others - **Clear contract**: Interface defines what extraction must provide @@ -41,4 +41,43 @@ val extractors = mapOf( ) ``` -Future engines (e.g., Flowable, jBPM) can be added by creating new extractor implementations. +Future engines (e.g., Flowable, jBPM) can be added without touching the existing ones. + +### Update (6.0, see ADR 017) + +The strategy still holds, but the unit of substitution moved one level down, and the +`EngineSpecificExtractor` interface was dropped with it — an interface with a single implementation is not +a strategy. Reading a BPMN file is now one concrete `ProcessModelReader` that combines the +engine-independent `BpmnStructureReader` and `BpmnDefinitionsReader` with an `EngineDialect`, and the +registry holds the dialects directly: + +```kotlin +val dialects = mapOf( + ProcessEngine.ZEEBE to ZeebeDialect(), + ProcessEngine.CAMUNDA_7 to CamundaDialect(CAMUNDA_7_NAMESPACE), + ProcessEngine.OPERATON to CamundaDialect(OPERATON_NAMESPACE), +) +``` + +This also retires the "Negative" consequences above: parsing logic is now shared by construction, and +common functionality is added once in the readers instead of in every extractor. + +A dialect is the largest part of adding an engine, but not the whole of it. `EngineDetector` has to learn +the new namespace, and the registry above needs an entry. Both are in the adapter layer, where engine +knowledge belongs. + +Two validation rules also branch on the engine, and they are worth telling apart: + +- `EngineMismatchRule` renders a display name (`"Zeebe (Camunda 8)"`). `ProcessEngine` is a domain enum, + so branching on it inside a domain rule is not a layering problem — the string is this rule's message + text, nothing more. Lifting it onto the enum was considered and rejected: it has exactly one caller, + and a hard-coded English label on a public domain type is a presentation decision the model should not + carry. +- `MissingServiceTaskImplementationRule` prints an engine-specific hint (`"Set camunda:topic …"`). This + *is* adapter vocabulary in the domain layer. It stays there deliberately. The domain may not depend on + the adapter, so the hint would have to arrive through `SingleModelValidationContext` — which changes + the surface every custom rule in `bpmn-to-code-testing` is written against, for two sentences of help + text. The check itself is engine-independent; only the advice is not. + + **Revisit when a third rule needs engine-specific text.** At that point the duplication is real rather + than hypothetical, and carrying an engine vocabulary on the validation context pays for itself. diff --git a/docs/contributing/adr/007-variable-extraction-scope.md b/docs/contributing/adr/007-variable-extraction-scope.md index 01d32705..7f436630 100644 --- a/docs/contributing/adr/007-variable-extraction-scope.md +++ b/docs/contributing/adr/007-variable-extraction-scope.md @@ -66,15 +66,15 @@ Extract **only explicitly defined variables** from `camunda:inputOutput` / `zeeb The scope of "explicit definitions" has expanded since the initial decision. All supported sources remain intentional BPMN declarations — no expression parsing is performed. -- **Camunda 7** (`Camunda7ModelExtractor.extractVariables()`): +- **Camunda 7** (`CamundaDialect.variablesOf()`): - `camunda:inputOutput` → `inputParameter`/`outputParameter` (I/O mappings) - `camunda:in`/`camunda:out` on call activities (call activity mappings) - `camunda:properties` with `name="additionalInputVariables"` / `name="additionalOutputVariables"` (directional extension properties, for elements like message start events that don't support I/O mappings) - `multiInstanceLoopCharacteristics` `camunda:collection` expression and `camunda:elementVariable` attribute -- **Zeebe** (`ZeebeModelExtractor.extractVariables()`): +- **Zeebe** (`ZeebeDialect.variablesOf()`): - `zeebe:ioMapping` → `input`/`output` elements - `zeebe:loopCharacteristics` `inputElement`/`inputCollection`/`outputElement`/`outputCollection` attributes -- **Operaton** (`OperatonModelExtractor.extractVariables()`): same as Camunda 7 using the `operaton:` namespace +- **Operaton** (`CamundaDialect.variablesOf()` with the `operaton:` namespace): same as Camunda 7 using the `operaton:` namespace - Expression parsing is not performed ## Subsequent decision (issue #290) diff --git a/docs/contributing/adr/010-operaton-namespace-only-extractor.md b/docs/contributing/adr/010-operaton-namespace-only-extractor.md index 80520c1a..2bdcff77 100644 --- a/docs/contributing/adr/010-operaton-namespace-only-extractor.md +++ b/docs/contributing/adr/010-operaton-namespace-only-extractor.md @@ -1,7 +1,7 @@ # ADR 010: Operaton Namespace-Only Extractor ## Status -Accepted +Accepted — implementation updated in 6.0 ## Context Operaton is a fork of Camunda 7 that uses its own namespace (`http://operaton.org/schema/1.0/bpmn`) for BPMN extension attributes. While Operaton can execute both Operaton-namespaced models and plain Camunda 7 models, we need to decide how to support Operaton in the extraction strategy. @@ -17,6 +17,13 @@ Implement `OperatonModelExtractor` to **only** support BPMN models with the Oper Users running Operaton with Camunda 7 models should use `ProcessEngine.CAMUNDA_7` configuration and the existing `Camunda7ModelExtractor`. +### Update (6.0, see ADR 017) + +The decision stands: Operaton models are read through the Operaton namespace only. What changed is that +Camunda 7 and Operaton no longer have separate extractor classes. Because the two share an identical +element and attribute vocabulary, both use `CamundaDialect` parameterised with their own namespace, which +removed the code duplication listed under *Consequences* below. + ## Rationale ### Simplicity diff --git a/docs/contributing/adr/012-json-export.md b/docs/contributing/adr/012-json-export.md index f73ae8d8..52139997 100644 --- a/docs/contributing/adr/012-json-export.md +++ b/docs/contributing/adr/012-json-export.md @@ -1,7 +1,11 @@ # ADR 012: JSON Export as a First-Class Output Format ## Status -Accepted +Superseded by [ADR 018](018-process-json-v2.md) + +> The decision to ship a JSON export at all still stands — the motivation and the determinism guarantee below +> are unchanged. The **format** described here is v1 and was replaced in 6.0.0 by the BPMN-standard-aligned +> v2 contract in [ADR 018](018-process-json-v2.md). ## Context bpmn-to-code's primary output is a Kotlin/Java constants file. That file is useful for compiler-checked references but is a compiled artifact — it requires building the project to inspect and cannot easily be consumed by non-JVM tooling. diff --git a/docs/contributing/adr/017-bpmn-aligned-domain-model.md b/docs/contributing/adr/017-bpmn-aligned-domain-model.md new file mode 100644 index 00000000..91fcfdf6 --- /dev/null +++ b/docs/contributing/adr/017-bpmn-aligned-domain-model.md @@ -0,0 +1,241 @@ +# ADR 017: BPMN-Aligned Sealed Domain Model for Flow Nodes + +## Status +Accepted + +## Context + +[ADR 014](014-shared-bpmn-types.md) and the two-axis `BpmnNodeType` refactor gave the domain a clean *identity* +model: a node knows whether it is a gateway, an event or an activity, and which subtype it is. What it does +**not** have is a clean *data* model. Everything a node carries beyond its identity goes through a single field: + +```kotlin +data class FlowNodeDefinition( + val nodeType: BpmnNodeType, + val properties: FlowNodeProperties = FlowNodeProperties.None, // ← one-of + val engineSpecificProperties: Map = emptyMap(), // ← untyped grab-bag + … +) + +sealed interface FlowNodeProperties { + object None; data class ServiceTask(…); data class Timer(…) + data class CallActivity(…); data class MessageEvent(…); data class SignalEvent(…) +} +``` + +`FlowNodeProperties` is a **one-of**, and each extractor picks exactly one variant — +`ZeebeModelExtractor.resolveProperties()` resolves service task *before* call activity *before* timer *before* +event. That was adequate while every node had at most one interesting facet. It stopped being adequate with two +concrete feature requests: + +- [#73](https://github.com/Miragon/bpmn-to-code/issues/73) — multi-instance loop characteristics, which apply to + service tasks, user tasks, call activities **and** sub-processes. +- [#74](https://github.com/Miragon/bpmn-to-code/issues/74) — `zeebe:ioMapping`, which per `zeebe-bpmn-moddle`'s + own `allowedIn` applies to call activities, events, receive tasks, service tasks, sub-processes and user tasks. + +Both are **additive facets**: a multi-instance call activity is still a call activity, and a service task with an +I/O mapping is still a service task. A one-of slot cannot hold two facets at once, so neither feature could be +implemented without either dropping data or bolting nullable fields onto the flat `FlowNodeDefinition`. + +Two further gaps surfaced while evaluating the model against the OMG BPMN 2.0 metamodel and `bpmn-moddle` +(see [ADR 018](018-process-json-v2.md) for the full comparison): + +- **Only one event definition survived.** `resolveEventDefinitionType()` used `firstNotNullOfOrNull`, while BPMN + allows a catch event to carry several triggers. `bpmn-moddle` models this as + `CatchEvent.eventDefinitions` — a *list*. +- **Nesting was a reference, not a structure.** `parentId` on the node, and nothing at all on + `SequenceFlowDefinition`, so a flow inside a sub-process was indistinguishable from a top-level one. + +## Decision + +Replace the identity axis (`BpmnNodeType`) plus one-of data axis (`FlowNodeProperties`) with a **single sealed +hierarchy that mirrors the BPMN class tree**, where each subtype carries exactly the facets BPMN permits on it. + +``` +sealed interface FlowNodeDefinition // id, name, incoming[], outgoing[], documentation, extensions[] +├── Gateway(kind: GatewayKind, default: String?) +├── Event(shape, eventDefinitions[], attachedToRef?, cancelActivity?, isInterrupting?, ioMapping?) +├── sealed interface Activity // multiInstance?, ioMapping?, boundaryEventRefs[], +│ │ // isForCompensation, default? +│ ├── Task(kind: TaskKind, implementation: TaskImplementation) +│ ├── SubProcess(kind: SubProcessKind, flowNodes[], sequenceFlows[]) +│ └── CallActivity(calledElement: CalledElement?) +└── Unknown +``` + +This keeps the property that made `BpmnNodeType` worth having — **invalid combinations are unrepresentable** — +and extends it from identity to data. A multi-instance gateway, a `calledElement` on an event, or a +`cancelActivity` flag on a task cannot be constructed. + +### Containment replaces `parentId` + +`Activity.SubProcess` owns its children **and its own sequence flows**, mirroring +`bpmn:FlowElementsContainer.flowElements`. Scope becomes structural instead of inferred, which is what the JSON +contract needs (ADR 018) and what makes an event sub-process or transaction boundary meaningful. + +The container has a name, `FlowScope`, but the models do not store it: + +```kotlin +data class FlowScope( + val flowNodes: List = emptyList(), + val sequenceFlows: List = emptyList(), +) +``` + +`ProcessModel`, `ProcessModel.Variant` and `Activity.SubProcess` each name their two halves as separate +fields — that is the shape consumers read. `FlowScope` is the shape the pair takes while it is being +*produced or transformed*: `BpmnStructureReader.read()` returns one instead of holding the halves as state, +and `ModelMergerService` merges and sorts one. Before it had a name, those two had independently grown their +own private `Scope` DTO for exactly this. + +Because merging, validation, collision detection and the code builders all reason over a flat node set, +`ProcessModel` exposes a derived DFS-flattened view: + +```kotlin +val allFlowNodes: List // depth-first, containers before their children +fun parentIdOf(nodeId: String): String? // derived from the tree +``` + +The tree is the store; the flat list is a projection. This mirrors how `bpmn-js` keeps a nested moddle tree +alongside a flat `ElementRegistry`. + +### Typed facets replace the untyped maps + +| Was | Now | +|---|---| +| `FlowNodeProperties.ServiceTask` + `ServiceTaskDefinition.engineSpecificProperties["implementationValue"]` | `TaskImplementation` — sealed: `JobWorker`, `Connector`, `ExternalTask`, `JavaClass`, `DelegateExpression`, `Expression`, `CalledDecision`, `Script`, `None` | +| `FlowNodeProperties.Timer` / `.MessageEvent` / `.SignalEvent` (one only) | `Event.eventDefinitions: List` — sealed: `Timer`, `Message`, `Signal`, `Error`, `Escalation`, `Compensation`, `Conditional`, `Link`, `Terminate` | +| *(not representable)* | `Activity.multiInstance: MultiInstanceDefinition?` (#73) | +| *(not representable)* | `ioMapping: IoMapping?` on `Activity` and `Event` (#74) | +| `engineSpecificProperties: Map` | `extensions: List` — namespaced `$type`, nested children, body text | +| `TimerDefinition.type: String?` (`"Duration"`) | `TimerType` enum (`DATE`, `DURATION`, `CYCLE`) | + +`TaskImplementation` subsumes the three parallel `ZeebeImplementationKind` / `Camunda7ImplementationKind` / +`OperatonImplementationKind` enums: the *kind* and its *payload* were previously split across an enum and a +string map, and are now one value. + +`EngineExtension` is a faithful projection of a foreign-namespace XML element — +`$type` (`prefix:localName`), its attributes, its children, its body — the same shape `moddle` produces via +`createAny()`. It is the lossless escape hatch for engine features we have not normalised (and for +[#42](https://github.com/Miragon/bpmn-to-code/issues/42)'s connector properties), and it carries namespace +provenance, which the old flat map did not. + +### Root-element registries are keyed correctly + +`MessageDefinition`, `SignalDefinition`, `ErrorDefinition` and `EscalationDefinition` previously stored the +**event node's** id, because they were built from `eventDefinition.parentElement`. In BPMN these are +`Definitions.rootElements` referenced by many events. They now key on the real root-element id, and the per-node +reference lives in the corresponding `EventDefinitionInstance` (`messageRef`, `signalRef`, `errorRef`, +`escalationRef`). A message reused by three events is now one registry entry with three references. + +### The generated Process API is preserved + +`ElementTypeName` is retained. It renders the flat `SERVICE_TASK` / `MESSAGE_START_EVENT` vocabulary for the +*code* API only, now deriving the event subtype from the first entry of `eventDefinitions`. The generated +Kotlin/Java files — including `BpmnRelations.elementType`, `previousElements` and `followingElements` — are +byte-identical before and after this ADR, verified by regenerating every fixture in `shared/bpmn` through +the full pipeline at both commits (see ADR 018). + +One deliberate exception was added later: root elements that nothing references used to be filtered out +during extraction, which meant the model quietly disagreed with the file. Filtering moved out of the +extractor into `UnreferencedRootElementRule`, so such a declaration now reaches the model — and produces a +constant — while the rule reports it. Re-running the same fixture comparison isolates the effect to 4 of 16 +generated files, each gaining exactly one constant. + +## Consequences + +### Positive +- #73 and #74 become straightforward field additions on the subtype that BPMN says owns them. +- Multi-trigger events are representable; no event definition is silently dropped. +- Scope is structural, so sequence flows finally know which container they belong to. +- Engine-specific data is namespaced, nested and lossless instead of a flat primitives-only map. +- Extractors get simpler: no priority chain deciding which single facet wins. +- Validation rules can pattern-match on the hierarchy (`is Activity.CallActivity`) instead of casting a + one-of `properties` field. + +### Negative +- Touches most of `bpmn-to-code-core` (~43 files including tests). Contained to that module — the Gradle, + Maven, web, runtime and testing modules do not reference these types. +- Consumers reading a node now switch on the sealed subtype rather than reading nullable fields — more + ceremony for simple traversals, which `allFlowNodes` mitigates. +- The tree/flat duality is one more concept to hold. Justified because both shapes have real consumers + (JSON output needs the tree; merging and codegen need the flat view). + +### One model type, not three + +`ProcessModel` is a single data class. `BpmnModel` (one file) and `MergedBpmnModel` (several files sharing a +process id) previously split it into a sealed hierarchy, but the two differed only in whether variants were +present — and every consumer asked exactly that, via `is MergedBpmnModel` or `as? BpmnModel`. That is now +`isMerged`, and the three type checks in the builders and the JSON mapper became property reads. + +The four `bpmn:Definitions` registries travel together as [RootElements] rather than as four parallel +fields. They were always merged, filtered and sorted as a unit, so the four-way repetition appeared nine +times across the model, the merger, the extractor and the mapper; merging, filtering and sorting now live +on the value object itself. + +`BpmnModelApi.engine` is `targetEngine`, distinct from `ProcessModel.detectedEngine`. Both were called +`engine`, so inside a builder `modelApi.engine` and `modelApi.model.engine` meant different things — +selected versus detected — with nothing in the names to say so. Their difference is exactly what +`EngineMismatchRule` reports. + +## Known follow-up: `VariableMapping` is a code-generation contract in the domain + +Every definition type implements `VariableMapping`, whose `getName()` returns an upper-snake-case **Java +identifier** and whose `getValue()` returns a `Pair` for errors and escalations — not +because a BPMN error *is* a pair, but because the generated `BpmnError(name, code)` constructor takes two +arguments. That is a code-generation ABI expressed as a domain interface, and it is why `TimerType.label` +carries the string `"Duration"`. + +It was left in place for 6.0 because moving it is not mechanical. `getName()` is consumed by the two API +builders — clearly adapter concerns — but also by `CollisionDetectionService`, which exists to predict +duplicate constant names in the generated API and is itself reachable from a domain validation rule. +Relocating the interface therefore forces a decision on whether "will the generated API collide?" is a +domain rule or an adapter rule. That deserves its own ADR rather than being settled inside a release. + +Two consequences to keep in mind meanwhile: `domain/shared/**` is excluded from the coverage gate, so the +name-normalisation logic in these types is unmeasured; and derived projections such as +`ProcessModel.serviceTasks` have to key on the node rather than on the generated name, because collapsing +by name in the domain hides distinct elements from validation. + +## Known follow-up: root-element names are still copied onto the node tree + +Referencing a root element needs one field — the `…Ref`. The node also carries the name, and for errors and +escalations the code as well: + +| `EventDefinitionInstance` | Reference | Copied from the registry entry | +|---|---|---| +| `Message` (via `MessageReference`) | `messageRef` | `messageName` | +| `Signal` | `signalRef` | `signalName` | +| `Error` | `errorRef` | `errorName`, `errorCode` | +| `Escalation` | `escalationRef` | `escalationName`, `escalationCode` | + +The **published JSON has none of this** — `EventDefinitionJson` emits only the `…Ref`, and names and codes +appear exactly once, in `definitions`. The duplication is domain-internal. + +It remains because the correlation rules match on names rather than ids: `messageUsages()` and +`signalUsages()` build their `NamedEventUsage` from the copied name, and `MissingErrorDefinitionRule` uses +`errorName == null || errorCode == null` to detect a half-configured error — it needs to observe the +*absence* on the node. + +Resolving through the registry instead is a contained change (five call sites), but it changes what those +rules mean: after `withReferencedDefinitionsOnly()` a node with a null `…Ref` has nothing to resolve, so +"incomplete definition" needs a new formulation. Worth doing on its own, not folded into a release. + +Timers and compensations have no such problem and need no follow-up: BPMN defines neither as a root +element, so their data lives inline on the event definition and `ProcessModel.timers` / `.compensations` +are derived views recomputed from the node tree, which cannot drift. + +## Alternatives Considered + +**Add nullable `multiInstance` / `ioMapping` fields to the flat `FlowNodeDefinition`.** Smallest change, but it +makes "a multi-instance parallel gateway" constructible and pushes validity checks to runtime — the exact +problem `BpmnNodeType` was introduced to solve. + +**Keep `FlowNodeProperties` and add an `Activity` grouping variant carrying `multiInstance`.** Solves #73 alone, +but not #74 (I/O mappings are legal on events too, which the activity grouping cannot express) and not the +multi-trigger event gap. It also keeps the one-of, so a service task with both an implementation and an I/O +mapping still would not fit. + +**Model nesting only in the JSON adapter and keep the domain flat.** Rejected: the domain would still be unable +to answer "which scope does this sequence flow belong to", so validation rules and future reachability analysis +([#48](https://github.com/Miragon/bpmn-to-code/issues/48)) would each have to rebuild the tree themselves. diff --git a/docs/contributing/adr/018-process-json-v2.md b/docs/contributing/adr/018-process-json-v2.md new file mode 100644 index 00000000..13fbed6a --- /dev/null +++ b/docs/contributing/adr/018-process-json-v2.md @@ -0,0 +1,233 @@ +# ADR 018: Process JSON v2 — a BPMN-Standard-Aligned Public Contract + +## Status +Accepted — supersedes [ADR 012](012-json-export.md) + +## Context + +[ADR 012](012-json-export.md) introduced the JSON export as a second output format for AI assistants and for +reviewing process changes as text. It has since grown feature by feature and is about to be treated as a +**stable, public contract**. [Issue #58](https://github.com/Miragon/bpmn-to-code/issues/58) asked to evaluate it +against established non-XML BPMN domain models before locking it down. + +### What we compared against + +Verified against the actual descriptors — `bpmn-moddle@10`'s `bpmn.json`, `camunda-bpmn-moddle@7`, +`zeebe-bpmn-moddle`, and the element-templates JSON schema. Camunda 8 has no public JSON process-model +representation (deployment is XML), so it is not a usable reference. + +| Concern | bpmn-moddle (OMG-derived) | JSON v1 | +|---|---|---| +| Node type | `$type: "bpmn:StartEvent"` + `eventDefinitions: []` — an **array**, multi-trigger events are legal | one flattened string `MESSAGE_START_EVENT`; only one definition survives | +| Nesting | `FlowElementsContainer.flowElements` on `Process` and `SubProcess` | flat list + `parentId`; sequence flows carry no scope at all | +| Relations | `SequenceFlow.sourceRef/targetRef` plus `FlowNode.incoming/outgoing` → **SequenceFlow** ids | node→node `previousElements` / `followingElements` **and** a top-level `sequenceFlows` array | +| Boundary events | `attachedToRef`, `Activity.boundaryEventRefs`, `cancelActivity` (default `true`) | `attachedToRef`, `attachedElements`, `interrupting` | +| Loop characteristics | `Activity.loopCharacteristics` → `MultiInstanceLoopCharacteristics` | absent | +| Root elements | `Definitions.rootElements` — `bpmn:Message` / `Signal` / `Error` / `Escalation`, referenced by `…Ref` | top-level lists keyed by the *event node's* id | +| Extensions | `extensionElements.values[]`, namespaced `$type`, arbitrarily nested; unknown content preserved | flat `Map`, primitives only, no namespace | +| Where an extension is legal | machine-readable `allowedIn` | not modelled | +| Versioning | schema per package + uri | no `$schema`, no format version | + +### Concrete defects in v1 + +1. **Relations encoded three times** (`previousElements`, `followingElements`, `sequenceFlows`), and node→node + adjacency loses *which* flow was taken — so a condition or default branch cannot be attributed to a target. +2. **`parentId` cannot scope sequence flows.** `SequenceFlowJson` has no scope field; + `MergedBpmnModel.sequenceFlows` even returns an empty list. +3. **`properties` is a single one-of slot**, so multi-instance ([#73](https://github.com/Miragon/bpmn-to-code/issues/73)) + and `zeebe:ioMapping` ([#74](https://github.com/Miragon/bpmn-to-code/issues/74)) are unrepresentable — + see [ADR 017](017-bpmn-aligned-domain-model.md). +4. **Only one event definition survives**, though BPMN permits several triggers on one catch event. +5. **The top-level registries are mis-keyed** — `messages[].id` … `compensations[].id` hold the *event node's* + id, not the `bpmn:Message` / `Signal` / `Error` root-element id, so a message reused by three events appears + three times and cannot be de-duplicated or resolved. +6. **`compensations[].activityRef` was wrong** — it emitted the compensation event's own id, while the real + reference sat unused in `engineSpecificProperties`. +7. **`engineSpecificProperties` cannot hold structured data** — any non-primitive was coerced to + `toString()`, and keys carried no namespace, so C7 and Zeebe keys could collide with no provenance. +8. **Variable direction was dropped**: the generated *code* API splits `Inputs` / `Outputs` + ([ADR 015](015-directional-variable-extraction.md)), the JSON emitted a flat `List`. +9. **Process metadata already in the domain was discarded** — `isExecutable`, the detected engine, the process + name. +10. **No `$schema` and no format version**, so consumers could neither pin nor detect a break. +11. **`variants` made one file carry two shapes** — either `flowNodes` or `variants` was populated. +12. **The documentation was already out of sync**, describing `incoming` / `outgoing` while the code emitted + `previousElements` / `followingElements` — borrowing moddle's field names for different semantics. + +## Decision + +Publish a **v2 contract** that is structurally aligned with OMG BPMN 2.0 / `bpmn-moddle`, versioned by a +published JSON Schema, and layered so that standard data, normalised cross-engine data and raw engine data are +each in a well-defined place. + +### The three layers, per element + +1. **BPMN-standard core** — names and shapes taken from the OMG metamodel: `id`, `name`, `type` (the BPMN + element's local name), containment, `incoming` / `outgoing` (sequence-flow ids), `eventDefinitions[]`, + `attachedToRef`, `cancelActivity`, `triggeredByEvent`, `boundaryEventRefs`, `default`, `isForCompensation`, + `documentation`. +2. **Normalised cross-engine facets** — bpmn-to-code's own value-add, identical whether the source was + `zeebe:*` or `camunda:*`: `implementation`, `ioMapping`, `variables`, `multiInstance`, `calledElement`, and + the event-definition payloads (`timerType` / `expression`, `subscription.correlationKey`). +3. **Raw engine extensions** — `extensions[]` with a namespaced `$type`, plus `attributes`, nested `children` and `body`, and + `engineAttributes` for foreign-namespace *attributes*. New engine features show up here without a schema + change. + + This layer carries what layer 2 does **not**. Anything a dialect reads in full is left out, because + emitting both would state the same fact twice and let the two drift apart. + + For *elements* that means, in Zeebe, `taskDefinition`, `ioMapping`, `loopCharacteristics` and + `calledElement`. Partially read elements stay: `camunda:inputParameter` may nest a `camunda:script` + that `ioMapping` does not carry, so the raw form remains the only way to reach it. + + For *attributes* the same rule applies, but resolved per node rather than as a fixed list. + `camunda:topic`, `camunda:delegateExpression`, `camunda:class` and `camunda:expression` all map to the + same `implementation` field and are mutually exclusive by precedence — a model declaring two would see + only one normalised, so only the winner is dropped and the loser stays reachable. Attributes with no + typed counterpart (`camunda:asyncBefore`, `camunda:exclusive`, `camunda:type`) always stay. + +### Shape + +```jsonc +{ + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "newsletterSubscription", + "name": "Newsletter Subscription", + "isExecutable": true, + "engine": "ZEEBE", + "flowNodes": [ + { "id": "StartEvent_SubmitRegistrationForm", "type": "startEvent", "name": "Submit newsletter form", + "outgoing": ["Flow_1csfyyz"], + "eventDefinitions": [ + { "type": "message", "messageRef": "Message_1", + "subscription": { "correlationKey": "=subscriptionId" } } + ], + "variables": [ { "name": "subscriptionId", "direction": "OUTPUT" } ] }, + + { "id": "SubProcess_Confirmation", "type": "subProcess", "triggeredByEvent": false, + "incoming": ["Flow_0zdmt0t"], "outgoing": ["Flow_09cuvzp"], + "boundaryEventRefs": ["ErrorEvent_InvalidMail", "Timer_After3Days"], + "flowNodes": [ + { "id": "Activity_SendConfirmationMail", "type": "serviceTask", + "incoming": ["Flow_05i3x1y"], "outgoing": ["Flow_1bckm43"], + "implementation": { "type": "jobWorker", "jobType": "newsletter.sendConfirmationMail" }, + "ioMapping": { "inputs": [ { "target": "subscriptionId", "source": "=subscriptionId" } ] }, + "multiInstance": { "sequential": true, "inputCollection": "=items", "inputElement": "item" }, + "extensions": [ { "$type": "zeebe:taskHeaders", + "children": [ { "$type": "zeebe:header", + "attributes": { "key": "k", "value": "v" } } ] } ] }, + { "id": "Timer_EveryDay", "type": "boundaryEvent", "name": "Every day", + "attachedToRef": "Activity_ConfirmRegistration", "cancelActivity": false, + "outgoing": ["Flow_0x4ewvb"], + "eventDefinitions": [ { "type": "timer", "timerType": "DURATION", "expression": "PT1M" } ] } + ], + "sequenceFlows": [ + { "id": "Flow_05i3x1y", "sourceRef": "StartEvent_RequestReceived", + "targetRef": "Activity_SendConfirmationMail" } + ] } + ], + "sequenceFlows": [ + { "id": "Flow_16hub0n", "sourceRef": "Gateway_SplitNotifications", + "targetRef": "Activity_SendWelcomeMail", "name": "in stock", "conditionExpression": "=stock > 0" } + ] + }, + "definitions": { + "messages": [ { "id": "Message_1", "name": "Message_FormSubmitted" } ], + "signals": [ { "id": "Signal_1", "name": "Signal_RegistrationNotPossible" } ], + "errors": [ { "id": "Error_1", "name": "Error_InvalidMail", "errorCode": "500" } ], + "escalations": [] + } +} +``` + +### Deltas from v1, and why + +| Change | Defect addressed | +|---|---| +| `previousElements` / `followingElements` → `incoming` / `outgoing` holding **sequence-flow ids** | 1, 12 — moddle semantics; the flow carries its own name, condition and default flag, so a branch is finally attributable | +| `parentId` → real containment; each scope owns its `flowNodes` **and** `sequenceFlows` | 2 | +| `properties` one-of → `implementation` + `eventDefinitions[]` + `multiInstance?` + `ioMapping?` | 3, 4, #73, #74 | +| `elementType: "MESSAGE_START_EVENT"` → `type: "startEvent"` + `eventDefinitions[]` | 4 — maps 1:1 onto moddle by prefixing `bpmn:` | +| `interrupting` → `cancelActivity` (boundary) / `isInterrupting` (event sub-process start) | BPMN attribute names | +| `isDefault` on the flow → `default` on the gateway / activity | OMG places it on the source element | +| Registries keyed by root-element id; nodes reference them via `messageRef` / `signalRef` / `errorRef` / `escalationRef` | 5 | +| Compensation becomes a per-node event definition with the real `activityRef` and `waitForCompletion` | 6 | +| `engineSpecificProperties` → `extensions[]` (namespaced, nested) + `engineAttributes` | 7, and unblocks [#42](https://github.com/Miragon/bpmn-to-code/issues/42) | +| `variables: ["x"]` → `[{ name, direction, expression }]` | 8 — parity with the code API | +| Added `process.name`, `process.isExecutable`, `process.engine` | 9 | +| Added `$schema` and `formatVersion` | 10 | +| `flowNodes` / `sequenceFlows` are **always** present (the union for merged models); `variants` is purely additive | 11 | + +### Versioning + +The schema is published with the documentation at +`https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json` (source: +`docs/public/schema/process-model/2.0.json`) and referenced from every generated file via `$schema`. +Additive changes bump the minor version and reuse the same document; any breaking change gets a new +major-versioned schema URL, so a consumer can pin exactly what it parses. + +### Determinism is preserved + +ADR 012's guarantee stands: same BPMN in, byte-identical JSON out. Depth-first ordering now applies **per +scope**; registries and extension entries are emitted in a deterministic order. + +### Registries hold only referenced root elements + +A BPMN file may declare a `bpmn:Message`, `Signal`, `Error` or `Escalation` that no element points at — +usually left over from editing in a modeler. Those are dropped: `definitions` exists so a node's `…Ref` can +be resolved, and a generated constant for a message nothing listens to is misleading. This matches what v1 +surfaced, which only ever saw root elements through the events that referenced them. + +### The generated Process API is unaffected + +Only the JSON contract breaks. The Kotlin/Java Process API — including `BpmnRelations` — is **byte-identical**. + +This was verified end-to-end rather than assumed: the full pipeline (load → extract → merge → generate) was +run over every fixture in `shared/bpmn` for all three engines and both output languages, at this commit and +at the last commit before the refactor, and all 18 generated files matched byte-for-byte. + +The expected-API golden files in `bpmn-to-code-core/src/test/resources/api` remain the day-to-day regression +guard. Their constant *ordering* changed in this refactor, because they are generated from a hand-built test +fixture that bypasses the merger; every production path merges first, and merging has always sorted flow +nodes by id, so the emitted order is unchanged for real models. The writers now sort explicitly, which is a +no-op after merging and keeps output deterministic for any caller that skips it. + +## Consequences + +### Positive +- The output is now mappable 1:1 onto `bpmn-moddle` (prefix `type` with `bpmn:`), so bpmn-io tooling and any + consumer that already knows BPMN needs no translation table. +- #73 and #74 are covered, and #42 has a place to land without another schema break. +- Engine-specific data is namespaced, nested and lossless, so future engine features do not force schema churn. +- Consumers can pin a schema version and validate mechanically. +- Scoped sequence flows and attributable branch conditions make the file genuinely analysable, not just readable. + +### Negative +- **Breaking for every JSON consumer.** Field names, nesting and the event vocabulary all change; there is no + compatibility shim. Released as **6.0.0** with a migration section in the changelog. +- "Give me every node" now needs recursion (`jq '.. | .flowNodes? // empty | .[]'`) instead of one array read. +- "What runs next" costs one join through `sequenceFlows` instead of a direct field read — mitigated by flows + living in the same scope object as the nodes that reference them. + +## Alternatives Considered + +**Stay flat and only add `scopeId` to nodes and flows.** Smallest break and the friendliest shape for `grep` / +`jq` / pasting into an LLM, but nesting stays a reference rather than a structure — which is the substance of +issue #58's complaint — and `children[]` plus `scopeId` reintroduces a smaller version of the redundancy we set +out to remove. + +**Keep node→node adjacency alongside `sequenceFlows` for readability.** Best ergonomics for "what comes after +X", and it would feed [#54](https://github.com/Miragon/bpmn-to-code/issues/54) directly, but it keeps the +duplication the issue asks us to remove and still cannot attribute a condition to a branch. + +**Emit only `sequenceFlows[]` with no relation fields on nodes.** Fully normalised and smallest, but every +consumer would have to build an index before answering anything — even moddle does not go this far. + +**Keep `MESSAGE_START_EVENT` as a convenience field alongside `eventDefinitions[]`.** No consumer would have to +change, but it is derived data that cannot represent a multi-trigger event, so it would be wrong exactly where +it matters. + +**Add lanes, data objects, artifacts and collaboration in the same pass.** Deferred: none has a concrete +consumer yet, and the layered shape means they can be added additively within `2.x`. diff --git a/docs/contributing/adr/index.md b/docs/contributing/adr/index.md index 928885bf..c9b1d8a2 100644 --- a/docs/contributing/adr/index.md +++ b/docs/contributing/adr/index.md @@ -49,6 +49,7 @@ Document decisions that: ### Core Architecture - [ADR 001: Hexagonal Architecture](001-hexagonal-architecture.md) - Clean architecture with ports and adapters - [ADR 002: Model Merging](002-model-merging.md) - Combining multiple BPMN files into single API +- [ADR 017: BPMN-Aligned Domain Model](017-bpmn-aligned-domain-model.md) - Sealed flow-node hierarchy mirroring the BPMN class tree ### Code Generation - [ADR 003: Generated API Structure](003-generated-api-structure.md) - Structure of generated Process APIs @@ -62,7 +63,8 @@ Document decisions that: - [ADR 006: File-Based Versioning](006-file-based-versioning.md) - API versioning strategy (deprecated — feature removed) - [ADR 007: Variable Extraction Scope](007-variable-extraction-scope.md) - Explicit variable definitions only - [ADR 011: Variable Name Collision Detection](011-variable-name-collision-detection.md) - Handling duplicate variable names -- [ADR 012: JSON Export](012-json-export.md) - Structured JSON representation of process models +- [ADR 012: JSON Export](012-json-export.md) - Structured JSON representation of process models (superseded by ADR 018) +- [ADR 018: Process JSON v2](018-process-json-v2.md) - BPMN-standard-aligned, schema-versioned public JSON contract - [ADR 014: Shared BPMN Types](014-shared-bpmn-types.md) - Published `bpmn-to-code-runtime` artifact for shared types across modules - [ADR 015: Directional Variable Extraction](015-directional-variable-extraction.md) - Split `Variables.` into `Inputs` / `Outputs` - [ADR 016: Migration to the `io.miragon` Namespace](016-miragon-namespace-migration.md) - Rename to `io.miragon` with a deprecated backward-compat layer diff --git a/docs/getting-started/gradle.md b/docs/getting-started/gradle.md index 1624e5f1..b4c9dfcc 100644 --- a/docs/getting-started/gradle.md +++ b/docs/getting-started/gradle.md @@ -8,13 +8,13 @@ The bpmn-to-code Gradle plugin generates type-safe Process API files from your B ```kotlin [build.gradle.kts] plugins { - id("io.miragon.bpmn-to-code-gradle") version "3.0.0" + id("io.miragon.bpmn-to-code-gradle") version "6.0.0" } ``` ```groovy [build.gradle] plugins { - id 'io.miragon.bpmn-to-code-gradle' version '3.0.0' + id 'io.miragon.bpmn-to-code-gradle' version '6.0.0' } ``` diff --git a/docs/getting-started/maven-advanced.md b/docs/getting-started/maven-advanced.md index 7f09c88c..9cd1f0f2 100644 --- a/docs/getting-started/maven-advanced.md +++ b/docs/getting-started/maven-advanced.md @@ -10,7 +10,7 @@ Add separate `` blocks with their own `` to generate f io.miragon bpmn-to-code-maven - 3.0.0 + 6.0.0 @@ -80,7 +80,7 @@ bpmn-to-code processes all files matching the `filePattern` glob — it has no b io.miragon bpmn-to-code-maven - 3.0.0 + 6.0.0 generate-bpmn-api diff --git a/docs/getting-started/maven.md b/docs/getting-started/maven.md index 45c47f6f..808b5d87 100644 --- a/docs/getting-started/maven.md +++ b/docs/getting-started/maven.md @@ -12,7 +12,7 @@ Add the following to the `` section of your `pom.xml`: io.miragon bpmn-to-code-maven - 3.0.0 + 6.0.0 diff --git a/docs/overview/why.md b/docs/overview/why.md index a0addafc..4eda6166 100644 --- a/docs/overview/why.md +++ b/docs/overview/why.md @@ -72,15 +72,19 @@ The standalone `validateBpmnModels` Gradle task (and `validate-bpmn` Maven goal) ### Surface — Process Structure in Code -bpmn-to-code generates a structured JSON file alongside the Kotlin/Java API. It contains every flow node with its display name, element type, sequence flows, and variables — sorted in process-flow order (DFS from start events). +bpmn-to-code generates a structured JSON file alongside the Kotlin/Java API. It contains every flow node with its name, BPMN element type, sequence flows, and variables — sorted in process-flow order (DFS from start events). The format follows the OMG BPMN 2.0 metamodel and is validated against a [published JSON Schema](https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json). ```json { - "processId": "newsletterSubscription", - "flowNodes": [ - { "id": "StartEvent_SubmitRegistrationForm", "displayName": "Submit newsletter form", "elementType": "START_EVENT" }, - { "id": "Activity_SendConfirmationMail", "displayName": "Send confirmation mail", "elementType": "SERVICE_TASK" } - ] + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "newsletterSubscription", + "flowNodes": [ + { "id": "StartEvent_SubmitRegistrationForm", "type": "startEvent", "name": "Submit newsletter form" }, + { "id": "Activity_SendConfirmationMail", "type": "serviceTask", "name": "Send confirmation mail" } + ] + } } ``` diff --git a/docs/public/schema/process-model/2.0.json b/docs/public/schema/process-model/2.0.json new file mode 100644 index 00000000..2b63081b --- /dev/null +++ b/docs/public/schema/process-model/2.0.json @@ -0,0 +1,308 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "title": "bpmn-to-code process model", + "description": "Structured, BPMN-standard-aligned representation of a BPMN process, generated by bpmn-to-code. See ADR 018.", + "type": "object", + "required": ["process"], + "properties": { + "$schema": { "type": "string", "format": "uri" }, + "formatVersion": { "type": "string", "const": "2.0" }, + "process": { "$ref": "#/$defs/process" }, + "definitions": { "$ref": "#/$defs/definitions" }, + "variants": { + "type": "array", + "description": "Per-variant subsets of a merged model. Additive: process.flowNodes holds the union.", + "items": { "$ref": "#/$defs/variant" } + } + }, + "additionalProperties": false, + "$defs": { + "process": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "isExecutable": { "type": "boolean", "default": true }, + "engine": { "enum": ["ZEEBE", "CAMUNDA_7", "OPERATON"] }, + "flowNodes": { "type": "array", "items": { "$ref": "#/$defs/flowNode" } }, + "sequenceFlows": { "type": "array", "items": { "$ref": "#/$defs/sequenceFlow" } } + }, + "additionalProperties": false + }, + "variant": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "flowNodes": { "type": "array", "items": { "$ref": "#/$defs/flowNode" } }, + "sequenceFlows": { "type": "array", "items": { "$ref": "#/$defs/sequenceFlow" } } + }, + "additionalProperties": false + }, + "definitions": { + "type": "object", + "description": "bpmn:Definitions root elements, de-duplicated by their own id and referenced from event definitions.", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "correlationKey": { + "type": "string", + "description": "Zeebe zeebe:subscription expression, declared on the message element itself." + } + }, + "additionalProperties": false + } + }, + "signals": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + }, + "additionalProperties": false + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "errorCode": { "type": "string" } + }, + "additionalProperties": false + } + }, + "escalations": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "escalationCode": { "type": "string" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "flowNode": { + "type": "object", + "required": ["id", "type"], + "properties": { + "id": { "type": "string" }, + "type": { + "description": "The BPMN element name. Prefix with 'bpmn:' to get the bpmn-moddle $type.", + "enum": [ + "task", + "serviceTask", + "userTask", + "receiveTask", + "sendTask", + "scriptTask", + "manualTask", + "businessRuleTask", + "subProcess", + "transaction", + "callActivity", + "exclusiveGateway", + "parallelGateway", + "inclusiveGateway", + "eventBasedGateway", + "complexGateway", + "startEvent", + "endEvent", + "intermediateCatchEvent", + "intermediateThrowEvent", + "boundaryEvent", + "unknown" + ] + }, + "name": { "type": "string" }, + "incoming": { + "type": "array", + "description": "Ids of incoming sequence flows (not of the preceding nodes).", + "items": { "type": "string" } + }, + "outgoing": { + "type": "array", + "description": "Ids of outgoing sequence flows (not of the following nodes).", + "items": { "type": "string" } + }, + "default": { "type": "string", "description": "Id of the default sequence flow." }, + "attachedToRef": { "type": "string", "description": "Host activity of a boundary event." }, + "cancelActivity": { "type": "boolean", "description": "Boundary events: whether the event interrupts its host." }, + "isInterrupting": { "type": "boolean", "description": "Event sub-process start events: whether the event interrupts its scope." }, + "triggeredByEvent": { "type": "boolean", "description": "Marks a sub-process as an event sub-process." }, + "isForCompensation": { "type": "boolean" }, + "boundaryEventRefs": { "type": "array", "items": { "type": "string" } }, + "eventDefinitions": { "type": "array", "items": { "$ref": "#/$defs/eventDefinition" } }, + "messageRef": { "type": "string", "description": "Send/receive tasks reference their message directly." }, + "implementation": { "$ref": "#/$defs/implementation" }, + "calledElement": { "$ref": "#/$defs/calledElement" }, + "multiInstance": { "$ref": "#/$defs/multiInstance" }, + "ioMapping": { "$ref": "#/$defs/ioMapping" }, + "variables": { "type": "array", "items": { "$ref": "#/$defs/variable" } }, + "flowNodes": { + "type": "array", + "description": "Children of a sub-process scope.", + "items": { "$ref": "#/$defs/flowNode" } + }, + "sequenceFlows": { + "type": "array", + "description": "Sequence flows owned by a sub-process scope.", + "items": { "$ref": "#/$defs/sequenceFlow" } + }, + "extensions": { "type": "array", "items": { "$ref": "#/$defs/extension" } }, + "engineAttributes": { + "type": "object", + "description": "Foreign-namespace attributes, keyed as prefix:localName.", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "sequenceFlow": { + "type": "object", + "required": ["id", "sourceRef", "targetRef"], + "properties": { + "id": { "type": "string" }, + "sourceRef": { "type": "string" }, + "targetRef": { "type": "string" }, + "name": { "type": "string" }, + "conditionExpression": { "type": "string" } + }, + "additionalProperties": false + }, + "eventDefinition": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "enum": [ + "timer", + "message", + "signal", + "error", + "escalation", + "compensation", + "conditional", + "link", + "terminate" + ] + }, + "timerType": { "enum": ["DATE", "DURATION", "CYCLE"] }, + "expression": { "type": "string" }, + "messageRef": { "type": "string" }, + "signalRef": { "type": "string" }, + "errorRef": { "type": "string" }, + "escalationRef": { "type": "string" }, + "activityRef": { "type": "string" }, + "waitForCompletion": { "type": "boolean" }, + "linkName": { "type": "string" } + }, + "additionalProperties": false + }, + "implementation": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "enum": [ + "jobWorker", + "connector", + "externalTask", + "javaClass", + "delegateExpression", + "expression" + ] + }, + "jobType": { "type": "string" }, + "templateId": { "type": "string" }, + "retries": { "type": "string" }, + "topic": { "type": "string" }, + "className": { "type": "string" }, + "expression": { "type": "string" } + }, + "additionalProperties": false + }, + "calledElement": { + "type": "object", + "properties": { + "processId": { "type": "string" }, + "propagateAllInputVariables": { "type": "boolean" }, + "propagateAllOutputVariables": { "type": "boolean" } + }, + "additionalProperties": false + }, + "multiInstance": { + "type": "object", + "required": ["sequential"], + "properties": { + "sequential": { "type": "boolean" }, + "inputCollection": { "type": "string" }, + "inputElement": { "type": "string" }, + "outputCollection": { "type": "string" }, + "outputElement": { "type": "string" }, + "cardinality": { "type": "string" }, + "completionCondition": { "type": "string" } + }, + "additionalProperties": false + }, + "ioMapping": { + "type": "object", + "properties": { + "inputs": { "type": "array", "items": { "$ref": "#/$defs/ioParameter" } }, + "outputs": { "type": "array", "items": { "$ref": "#/$defs/ioParameter" } } + }, + "additionalProperties": false + }, + "ioParameter": { + "type": "object", + "required": ["target"], + "properties": { + "target": { "type": "string" }, + "source": { "type": "string" } + }, + "additionalProperties": false + }, + "variable": { + "type": "object", + "required": ["name", "direction"], + "properties": { + "name": { "type": "string" }, + "direction": { "enum": ["INPUT", "OUTPUT"] }, + "expression": { "type": "string" } + }, + "additionalProperties": false + }, + "extension": { + "type": "object", + "description": "Verbatim projection of a foreign-namespace element below bpmn:extensionElements.", + "required": ["$type"], + "properties": { + "$type": { "type": "string", "description": "prefix:localName, e.g. zeebe:taskHeaders" }, + "attributes": { "type": "object", "additionalProperties": { "type": "string" } }, + "children": { "type": "array", "items": { "$ref": "#/$defs/extension" } }, + "body": { "type": "string" } + }, + "additionalProperties": false + } + } +} diff --git a/docs/surface/json.md b/docs/surface/json.md index d1a1d0f5..f8d6c670 100644 --- a/docs/surface/json.md +++ b/docs/surface/json.md @@ -1,10 +1,31 @@ # 📡 JSON Export -bpmn-to-code generates a structured JSON file alongside the Kotlin/Java API. It contains the full process structure — every flow node, sequence flow, message, signal, error, and compensation — in a format that both AI agents and developers can read directly. +bpmn-to-code generates a structured JSON file alongside the Kotlin/Java API. It contains the full process structure — every flow node, sequence flow, message, signal, error and escalation — in a format that both AI agents and developers can read directly. + +Since **6.0.0** the format follows the OMG BPMN 2.0 metamodel and the vocabulary of [`bpmn-moddle`](https://github.com/bpmn-io/bpmn-moddle): element names are the BPMN ones, a scope owns the elements it contains, and relations point at sequence flows. See [ADR 018](https://github.com/Miragon/bpmn-to-code/blob/main/docs/contributing/adr/018-process-json-v2.md) for the rationale, and the [v6 migration guide](/changelog/v6) if you consume the old format. ## What Gets Generated -For each process, a `.json` file is produced with the same base name as your BPMN file. The format is stable and deterministic — same BPMN in, same JSON out, on every run. +For each process, a `.json` file is produced, named after the process ID. The format is stable and deterministic — same BPMN in, same JSON out, on every run. + +Every file declares the schema it conforms to: + +```json +{ + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0" +} +``` + +The schema is published as [JSON Schema 2020-12](https://json-schema.org/), so consumers can validate the output and pin a version. It is closed (`additionalProperties: false`) — an unknown field means the file was produced by a newer bpmn-to-code than your schema. + +## Document Structure + +``` +process the bpmn:Process scope — metadata, flow nodes, sequence flows +definitions bpmn:Definitions root elements: messages, signals, errors, escalations +variants per-variant node sets, only for merged multi-variant models +``` ## Example @@ -12,79 +33,116 @@ For the newsletter subscription process: ```json { - "processId": "newsletterSubscription", - "flowNodes": [ - { - "id": "StartEvent_SubmitRegistrationForm", - "displayName": "Submit newsletter form", - "elementType": "MESSAGE_START_EVENT", - "outgoing": ["serviceTask_incrementSubscriptionCounter"], - "variables": ["subscriptionId"] - }, - { - "id": "serviceTask_incrementSubscriptionCounter", - "displayName": "Increment subscription counter", - "elementType": "SERVICE_TASK", - "attachedElements": ["CompensationEvent_OnSubscriptionCounter"], - "incoming": ["StartEvent_SubmitRegistrationForm"], - "outgoing": ["SubProcess_Confirmation"], - "properties": { - "type": "ServiceTask", - "implementationValue": "counterClass" - } - }, - { - "id": "SubProcess_Confirmation", - "displayName": "Subscription Confirmation", - "elementType": "SUB_PROCESS", - "attachedElements": ["ErrorEvent_InvalidMail", "Timer_After3Days"], - "incoming": ["serviceTask_incrementSubscriptionCounter"], - "outgoing": ["Activity_SendWelcomeMail"] - }, - { - "id": "Activity_SendWelcomeMail", - "displayName": "Send Welcome-Mail", - "elementType": "SERVICE_TASK", - "incoming": ["SubProcess_Confirmation"], - "outgoing": ["EndEvent_RegistrationCompleted"], - "variables": ["subscriptionId"], - "properties": { - "type": "ServiceTask", - "implementationValue": "newsletter.sendWelcomeMail" + "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json", + "formatVersion": "2.0", + "process": { + "id": "newsletterSubscription", + "isExecutable": true, + "engine": "ZEEBE", + "flowNodes": [ + { + "id": "StartEvent_SubmitRegistrationForm", + "type": "startEvent", + "name": "Submit newsletter form", + "outgoing": ["Flow_1csfyyz"], + "eventDefinitions": [ + { "type": "message", "messageRef": "Message_FormSubmitted" } + ], + "variables": [ + { "name": "subscriptionId", "direction": "OUTPUT" } + ] + }, + { + "id": "serviceTask_incrementSubscriptionCounter", + "type": "serviceTask", + "name": "Increment subscription counter", + "incoming": ["Flow_1csfyyz"], + "outgoing": ["Flow_0zdmt0t"], + "boundaryEventRefs": ["CompensationEvent_OnSubscriptionCounter"], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.incrementCounter" + } }, - "engineSpecificProperties": { - "asyncBefore": true, - "asyncAfter": true, - "exclusive": false + { + "id": "SubProcess_Confirmation", + "type": "subProcess", + "name": "Subscription Confirmation", + "incoming": ["Flow_0zdmt0t"], + "outgoing": ["Flow_09cuvzp"], + "boundaryEventRefs": ["ErrorEvent_InvalidMail", "Timer_After3Days"], + "flowNodes": [ + { + "id": "Activity_SendConfirmationMail", + "type": "serviceTask", + "name": "Send confirmation mail", + "incoming": ["Flow_05i3x1y"], + "outgoing": ["Flow_1bckm43"], + "implementation": { + "type": "jobWorker", + "jobType": "newsletter.sendConfirmationMail" + } + } + ], + "sequenceFlows": [ + { + "id": "Flow_05i3x1y", + "sourceRef": "StartEvent_RequestReceived", + "targetRef": "Activity_SendConfirmationMail" + } + ] } - } - ], - "messages": [ - { "id": "StartEvent_SubmitRegistrationForm", "name": "Message_FormSubmitted" } - ], - "signals": [ - { "id": "EndEvent_RegistrationNotPossible", "name": "Signal_RegistrationNotPossible" } - ], - "errors": [ - { "id": "ErrorEvent_InvalidMail", "name": "Error_InvalidMail", "code": "500" } - ], - "compensations": [ - { "id": "CompensationEndEvent_RegistrationAborted", "activityRef": "CompensationEndEvent_RegistrationAborted" } - ], - "sequenceFlows": [ - { - "id": "Flow_09cuvzp", - "sourceRef": "SubProcess_Confirmation", - "targetRef": "Activity_SendWelcomeMail", - "isDefault": false - } - ] + ], + "sequenceFlows": [ + { + "id": "Flow_09cuvzp", + "sourceRef": "SubProcess_Confirmation", + "targetRef": "Gateway_SplitNotifications" + } + ] + }, + "definitions": { + "messages": [ + { "id": "Message_FormSubmitted", "name": "Message_FormSubmitted" } + ], + "signals": [ + { "id": "Signal_RegistrationNotPossible", "name": "Signal_RegistrationNotPossible" } + ], + "errors": [ + { "id": "Error_InvalidMail", "name": "Error_InvalidMail", "errorCode": "500" } + ], + "escalations": [] + } } ``` +## Three layers per element + +Every flow node mixes three kinds of information, and knowing which is which tells you how stable a field is: + +| Layer | What it is | Stability | +|-------|-----------|-----------| +| **BPMN standard** | `id`, `name`, `type`, containment, `incoming` / `outgoing`, `eventDefinitions`, `attachedToRef`, `cancelActivity`, … | Defined by the OMG spec. Same for every engine. | +| **Normalised facets** | `implementation`, `ioMapping`, `multiInstance`, `variables`, `calledElement` | bpmn-to-code's own shape. Identical across engines; only the expressions inside stay engine-specific. | +| **Raw engine data** | `extensions`, `engineAttributes` | Verbatim, namespaced. Carries what layer 2 does not — an element read in full is left out. | + +## Containment and relations + +A sub-process owns its children **and its own sequence flows**, mirroring `bpmn:FlowElementsContainer`. A flow always knows which scope it belongs to, and there is no `parentId` to reconstruct nesting from. + +`incoming` and `outgoing` hold **sequence-flow IDs**, not node IDs — the same semantics as `bpmn:FlowNode.incoming` / `.outgoing`. To find the next node, resolve the flow: + +```js +const nextNodeIds = node.outgoing + .map(id => scope.sequenceFlows.find(f => f.id === id)) + .map(flow => flow.targetRef) +``` + +The extra hop is what makes conditions and default flows attributable: the flow object carries `conditionExpression`, and the gateway carries `default`. + ## Node Ordering -Flow nodes are sorted in **process-flow order** — a depth-first traversal from the start event(s). This means the JSON reads top-to-bottom in execution order, making it easy to understand the process narrative without tracing sequence flows manually. +Flow nodes are sorted in **process-flow order** — a depth-first traversal from the start event(s), per scope. This means the JSON reads top-to-bottom in execution order, without tracing sequence flows manually. Boundary events appear immediately after the element they are attached to. @@ -93,56 +151,145 @@ Boundary events appear immediately after the element they are attached to. | Field | Always present | Description | |-------|---------------|-------------| | `id` | yes | The BPMN element ID | -| `displayName` | yes | The element's label or name from the modeler | -| `elementType` | yes | The element's BPMN type, e.g. `SERVICE_TASK`, `USER_TASK`, `SUB_PROCESS`, `CALL_ACTIVITY`, `TASK`, … For event nodes the concrete event subtype is prefixed — see [Event subtypes](#event-subtypes). | -| `incoming` | no | IDs of incoming elements (sequence flow sources or parent subprocess) | -| `outgoing` | no | IDs of outgoing elements | -| `parentId` | no | Parent subprocess ID, if nested | -| `attachedToRef` | no | Element this boundary event is attached to | -| `interrupting` | no | For boundary events and event sub-process start events: whether the event is interrupting (`cancelActivity` / `isInterrupting`). Defaults to `true` when unset in the model; absent for all other nodes | -| `attachedElements` | no | Boundary events attached to this element | -| `variables` | no | Variable names extracted from I/O mappings | -| `properties` | no | Engine-specific implementation details (task type, calledElement, timer config) | -| `engineSpecificProperties` | no | Camunda 7 / Operaton async markers (`asyncBefore`, `asyncAfter`, `exclusive`) | - -## Event subtypes - -For event nodes, `elementType` carries the concrete event subtype directly, so you can tell a timer -from an error from a message without cross-referencing the `errors` / `messages` / `signals` / -`escalations` / `compensations` lists. The value is the subtype prefixed onto the BPMN shape: - -| Value | Meaning | -|-------|---------| -| `TIMER_BOUNDARY_EVENT` | boundary timer event | -| `ERROR_BOUNDARY_EVENT` | boundary error event | -| `MESSAGE_START_EVENT` | message start event | -| `SIGNAL_END_EVENT` | signal end event | -| `ESCALATION_END_EVENT` | escalation end event | -| `COMPENSATION_BOUNDARY_EVENT` | compensation boundary event | - -The pattern is `_` where `` is one of `TIMER`, `MESSAGE`, `ERROR`, `SIGNAL`, -`ESCALATION`, `COMPENSATION`, and `` is `START_EVENT`, `END_EVENT`, `BOUNDARY_EVENT`, -`INTERMEDIATE_CATCH_EVENT`, or `INTERMEDIATE_THROW_EVENT`. Plain events with no definition keep their -bare shape (e.g. `END_EVENT`). The top-level `errors` / `messages` / … lists still carry the extra -detail (error `code`, message `name`, …). - -Message events and receive tasks may carry engine-specific message details under a nested -`properties.engineSpecificProperties` object. For Zeebe this holds the `zeebe:subscription` -`correlationKey` (the FEEL expression, verbatim), present only where a subscription defines one: +| `type` | yes | The BPMN element name — `serviceTask`, `userTask`, `startEvent`, `boundaryEvent`, `subProcess`, `callActivity`, `exclusiveGateway`, … Prefix with `bpmn:` to get the `bpmn-moddle` `$type` | +| `name` | no | The element's label from the modeler | +| `incoming` / `outgoing` | no | IDs of the **sequence flows** entering and leaving this node | +| `default` | no | ID of the default sequence flow (on the gateway or activity that owns it) | +| `eventDefinitions` | no | Triggers and results of an event — a list, because BPMN allows several | +| `attachedToRef` | no | Host activity of a boundary event | +| `cancelActivity` | no | Boundary events: whether the event interrupts its host | +| `isInterrupting` | no | Event sub-process start events: whether the event interrupts its scope | +| `triggeredByEvent` | no | Marks a sub-process as an event sub-process | +| `boundaryEventRefs` | no | Boundary events attached to this activity | +| `isForCompensation` | no | Marks an activity as a compensation handler | +| `messageRef` | no | Send and receive tasks reference their message directly | +| `implementation` | no | How the engine runs this node — see below | +| `calledElement` | no | Call activity target plus variable-propagation flags | +| `multiInstance` | no | `bpmn:multiInstanceLoopCharacteristics`, normalised | +| `ioMapping` | no | Input/output parameter mapping, normalised | +| `variables` | no | Variables the node reads or writes, each with a direction | +| `flowNodes` / `sequenceFlows` | no | Children of a sub-process scope | +| `extensions` | no | Foreign-namespace elements, verbatim | +| `engineAttributes` | no | Foreign-namespace attributes, verbatim | + +## Event definitions + +An event carries a **list** of `eventDefinitions`, discriminated by `type`, because BPMN permits several triggers on one catch event: ```json -"properties": { - "type": "MessageEvent", - "messageName": "orderPlaced", - "messageDirection": "CATCH", - "engineSpecificProperties": { "correlationKey": "=orderId" } +"eventDefinitions": [ + { "type": "timer", "timerType": "DURATION", "expression": "PT1M" }, + { "type": "message", "messageRef": "Message_FormSubmitted" } +] +``` + +| `type` | Payload | +|--------|---------| +| `timer` | `timerType` (`DATE` / `DURATION` / `CYCLE`), `expression` | +| `message` | `messageRef` | +| `signal` | `signalRef` | +| `error` | `errorRef` | +| `escalation` | `escalationRef` | +| `compensation` | `activityRef`, `waitForCompletion` | +| `conditional` | `expression` | +| `link` | `linkName` | +| `terminate` | — | + +The `…Ref` fields resolve into `definitions`, where the name and code live. A message used by three events is **one** entry there, referenced three times. + +```json +"definitions": { + "messages": [ + { "id": "Message_FormSubmitted", "name": "Message_FormSubmitted", "correlationKey": "=subscriptionId" } + ] } ``` -::: warning Breaking change -Before this was introduced, event nodes always reported their bare shape (e.g. `BOUNDARY_EVENT`). -Consumers that match on the old shape-only values must be updated. -::: +`correlationKey` is the Zeebe `zeebe:subscription` expression. It is declared on the `bpmn:Message` element itself, so it belongs to the message rather than to each referencing event. + +## Implementation + +`implementation` says how the engine executes a node, normalised across engines and discriminated by `type`: + +```json +"implementation": { "type": "jobWorker", "jobType": "newsletter.sendWelcomeMail" } +``` + +| `type` | Engine | Payload | +|--------|--------|---------| +| `jobWorker` | Zeebe | `jobType`, `retries` | +| `connector` | Zeebe | `jobType`, `templateId`, `retries` | +| `externalTask` | Camunda 7 / Operaton | `topic` | +| `javaClass` | Camunda 7 / Operaton | `className` | +| `delegateExpression` | Camunda 7 / Operaton | `expression` | +| `expression` | Camunda 7 / Operaton | `expression` | + +A service task with nothing configured omits the field entirely — that is what the `missing-service-task-implementation` validation rule flags. + +## Multi-instance and I/O mappings + +Both are activity facets, present only where BPMN allows them. + +```json +{ + "id": "serviceTask_sendToSubscriber", + "type": "serviceTask", + "multiInstance": { + "sequential": true, + "inputCollection": "=subscribers", + "inputElement": "subscriber" + } +} +``` + +`zeebe:loopCharacteristics` and `camunda:collection` / `camunda:elementVariable` both map onto these fields, so the same logical loop reads identically for every engine. The expressions themselves are preserved verbatim — FEEL `=subscribers` for Zeebe, JUEL `${subscribers}` for Camunda 7 — because rewriting them would lose information. + +```json +"ioMapping": { + "inputs": [], + "outputs": [ + { "target": "subscribers", "source": "=subscribers" }, + { "target": "author", "source": "=author" } + ] +} +``` + +`zeebe:ioMapping` and `camunda:inputOutput` both normalise here. `target` is the variable being written, `source` the expression bound to it. + +## Engine-specific data + +Anything bpmn-to-code does not normalise is preserved verbatim, with its namespace prefix intact: + +```json +"extensions": [ + { + "$type": "zeebe:taskHeaders", + "children": [ + { "$type": "zeebe:header", "attributes": { "key": "priority", "value": "high" } } + ] + } +], +"engineAttributes": { "camunda:asyncBefore": true } +``` + +`extensions` mirrors `bpmn:extensionElements` and nests arbitrarily; `engineAttributes` holds foreign-namespace attributes on the element itself. A new engine feature shows up here without a schema change. + +What is already normalised is **not** repeated here. `zeebe:taskDefinition`, `zeebe:ioMapping`, `zeebe:loopCharacteristics` and `zeebe:calledElement` have typed fields (`implementation`, `ioMapping`, `multiInstance`, `calledElement`), so they are left out rather than stated twice. Camunda 7 and Operaton keep theirs: `camunda:inputParameter` can nest a `camunda:script`, `camunda:in`/`out` carry `businessKey` and `local`, and `camunda:properties` is read for two property names only — for those the raw element is the only complete source. + +`engineAttributes` follows the same rule. The attribute behind `implementation` — `camunda:topic`, `camunda:delegateExpression`, `camunda:class` or `camunda:expression`, whichever the engine's precedence picked — is left out; `camunda:asyncBefore`, `camunda:exclusive` and `camunda:type` have no typed counterpart and stay. If a task declares two implementation attributes, only the one that won is dropped, so the other is still reachable. + +## Multi-variant processes + +When several BPMN files declare the same process ID with different `variantName` values, `process.flowNodes` holds the union and `variants` carries each variant's own node set: + +```json +"variants": [ + { "name": "withApproval", "flowNodes": [ /* … */ ], "sequenceFlows": [ /* … */ ] }, + { "name": "express", "flowNodes": [ /* … */ ], "sequenceFlows": [ /* … */ ] } +] +``` + +`variants` is absent for single-file processes, so consumers can always read `process` and treat variants as additive. ## Configuring the JSON Task @@ -169,7 +316,7 @@ Run: io.miragon bpmn-to-code-maven - 3.0.0 + 6.0.0 generate-bpmn-json @@ -198,11 +345,11 @@ Run: The JSON is designed for use with AI coding assistants. Paste it into your assistant's context and ask questions about your process: -- "Which service tasks in this process are async?" +- "Which service tasks in this process run multi-instance?" - "What variables does the `Activity_SendConfirmationMail` task receive?" - "List all boundary events and what they are attached to." -Because the JSON is produced by deterministic rules — not generated by an LLM — the assistant gets reliable process context with no hallucinated element IDs. +Because the JSON is produced by deterministic rules — not generated by an LLM — the assistant gets reliable process context with no hallucinated element IDs. Aligning the vocabulary with the BPMN standard helps here too: a model that knows BPMN already knows what `boundaryEvent` and `cancelActivity` mean. ::: tip Keep the JSON in version control Commit the JSON alongside your BPMN files. This makes process structure changes visible in pull request diffs, without requiring reviewers to open Camunda Modeler. diff --git a/docs/validate/index.md b/docs/validate/index.md index cfac6c4c..9a980190 100644 --- a/docs/validate/index.md +++ b/docs/validate/index.md @@ -14,6 +14,7 @@ bpmn-to-code can validate your BPMN models against a set of built-in rules — i | `missing-message-name` | ERROR | Message event or receive task with no message name | | `missing-error-definition` | ERROR | Error boundary/end event with no error definition | | `missing-signal-name` | ERROR | Signal event with no signal name | +| `unreferenced-root-element` | WARN | Message, signal, error or escalation declared but referenced by nothing | | `missing-timer-definition` | ERROR | Timer event with no timer type or value | | `missing-called-element` | ERROR | Call activity with no `calledElement` reference | | `missing-element-id` | ERROR | Flow node with no ID · **mandatory** | @@ -66,7 +67,7 @@ BPMN validation failed: 1 error(s), 1 warning(s) io.miragon bpmn-to-code-maven - 3.0.0 + 6.0.0 validate-bpmn diff --git a/docs/validate/testing.md b/docs/validate/testing.md index 7aa85f4d..0cf0e2c4 100644 --- a/docs/validate/testing.md +++ b/docs/validate/testing.md @@ -14,7 +14,7 @@ Add it to your test scope, write a test, and your CI will catch modeling issues ```kotlin [Gradle] dependencies { - testImplementation("io.miragon:bpmn-to-code-testing:3.0.0") + testImplementation("io.miragon:bpmn-to-code-testing:6.0.0") } ``` @@ -22,7 +22,7 @@ dependencies { io.miragon bpmn-to-code-testing - 3.0.0 + 6.0.0 test ``` @@ -54,7 +54,7 @@ fun `BPMN models should have no violations`() { ## Selecting Rules -By default, `validate()` runs all 10 built-in rules. You can override the rule set: +By default, `validate()` runs all 11 built-in rules. You can override the rule set: ```kotlin BpmnValidator @@ -126,6 +126,7 @@ result.assertNoViolations("empty-process") // custom: assert a specific rule pr | Message event has no name | `MISSING_MESSAGE_NAME` | ERROR | Message start/catch/throw without a message name | | Error event has no definition | `MISSING_ERROR_DEFINITION` | ERROR | Error boundary/end event without error definition | | Signal event has no name | `MISSING_SIGNAL_NAME` | ERROR | Signal start/intermediate/end without signal name | +| Root element referenced by nothing | `UNREFERENCED_ROOT_ELEMENT` | WARN | Message/signal/error/escalation left over from an earlier model version | | Timer event has no definition | `MISSING_TIMER_DEFINITION` | ERROR | Timer event without type or value | | Call activity has no calledElement | `MISSING_CALLED_ELEMENT` | ERROR | Call activity without `calledElement` attribute | | Flow node has no ID | `MISSING_ELEMENT_ID` | ERROR | Any flow node missing an `id` attribute | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea80be84..bec3073e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ shadow = "9.4.2" slf4j = "2.0.18" kotlinLogging = "8.0.4" konsist = "0.17.3" +jsonSchemaValidator = "1.5.6" dokka = "2.2.0" detekt = "1.23.8" @@ -51,6 +52,9 @@ junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "jUnit" } assertj = { module = "org.assertj:assertj-core", version.ref = "assertJ" } junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatformLauncher" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +# Test-only: validates the generated process JSON against the published schema. +# Deliberately NOT part of the `testing` bundle — it pulls Jackson in, and only bpmn-to-code-core needs it. +jsonSchemaValidator = { module = "com.networknt:json-schema-validator", version.ref = "jsonSchemaValidator" } [bundles] codegen = ["kotlinpoet", "javapoet"]