├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build.gradle ├── dockerfile ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── micronaut-cli.yml ├── rules ├── TimerRules.kts ├── UserTaskRule.kts └── rule1.kts ├── settings.gradle └── src ├── main ├── kotlin │ └── com │ │ └── github │ │ └── stephenott │ │ └── workflowlinter │ │ ├── Application.kt │ │ ├── cleaner │ │ └── Cleaner.kt │ │ ├── controller │ │ ├── Helpers.kt │ │ ├── LinterController.kt │ │ └── ValidationResponses.kt │ │ └── linter │ │ ├── CommonLinterRules.kt │ │ ├── ElementValidatorFactories.kt │ │ ├── Extensions.kt │ │ ├── LinterCfg.kt │ │ ├── ValidationResult.kt │ │ ├── WorkflowLinter.kt │ │ └── kts │ │ ├── KtsLinterRulesCfg.kt │ │ └── LinterRulesFromKts.kt └── resources │ ├── META-INF │ └── services │ │ └── javax.script.ScriptEngineFactory │ ├── application.yml │ ├── logback.xml │ └── rules │ ├── rule1.kts │ └── ruleTimerMin.kts └── test ├── kotlin ├── com │ └── github │ │ └── stephenott │ │ └── workflowlinter │ │ └── LinterTest1.kt └── io │ └── kotlintest │ └── provided │ └── ProjectConfig.kt └── resources └── test1.bpmn /.gitignore: -------------------------------------------------------------------------------- 1 | Thumbs.db 2 | .DS_Store 3 | .gradle 4 | build/ 5 | target/ 6 | out/ 7 | .idea/ 8 | *.iml 9 | *.ipr 10 | *.iws 11 | .project 12 | .settings 13 | .classpath 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | 3 | jdk: openjdk8 4 | 5 | script: 6 | - ./gradlew clean build 7 | 8 | sudo: false 9 | 10 | install: true 11 | 12 | cache: 13 | directories: 14 | - $HOME/.gradle/caches/ 15 | - $HOME/.gradle/wrapper/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 https://github.com/StephenOTT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Workflow Linter Server 2 | 3 | Workflow Linter Server (WLS) currently serves three functions: 4 | 5 | 1. BPMN Linting through REST endpoints using Kotlin-Script (.kts) and YAML based configurations. 6 | 1. BPMN Sanitization: Using the linting capability, lint rules can be used to remove any xml content of a BPMN that is considered not sharable. This is used in cases where you want to share your BPMN and have it render in a BPMN viewer (say in [BPMN.js](https://bpmn.io)), but you do not want to example all of the internals of the BPMN's xml such as documentation, expressions, scripts, headers, IO mappings, custom extensions, etc. 7 | 1. OpenAPI/Swagger generator for the REST APIs defined by WLS. This provides easy HTTP-Client integration with various workflow services and tools such as a BPMN Modeler, Deployment Orchestration, CI, etc. 8 | 9 | # Setup 10 | 11 | 1. `./gradlew clean build` 12 | 1. `docker build . -t workflow-linter` 13 | 1. `docker run -d -p 8080:8080 -v myRules:/rules --name workflow-linter workflow-linter` 14 | 15 | make sure you setup your volume correctly so you can add rules. 16 | 17 | # Workflow Linting 18 | 19 | Workflow linting is currently targeting for BPMN workflows. 20 | 21 | Linting Rules can be configured using two methods: 22 | 23 | 1. YAML 24 | 1. Kotlin-Script (.kts) files 25 | 26 | Execution of linting rules is performed through a REST Endpoint 27 | 28 | ## Execution of Linting Rules: REST Endpoint 29 | 30 | **Option 1:** 31 | 32 | Create a Multi-Part Form with a file upload property. Property name must be `file`. 33 | 34 | POST `localhost:8080/workflow/bpmn/linter` 35 | 36 | If the linting is successful a status-code of `200` will be returned. 37 | 38 | 39 | **Option 2:** 40 | 41 | POST `localhost:8080/workflow/bpmn/linter` 42 | 43 | XML Body: xml of the BPMN. 44 | 45 | Content-Type: `application/xml` 46 | 47 | If the linting is successful a status-code of `200` will be returned. 48 | 49 | 50 | ### Linter Response 51 | 52 | The linter response will return a object with a `results` property. 53 | Each property in the `results` object is a elementId in the XML (BaseElement.class). 54 | This property contains a array of linting validation failures. 55 | 56 | For each linting validation failure, the following properties are provided: 57 | 58 | - `type`: the type of failure: `WARNING` or `ERROR` 59 | - `elementId`: the unique element ID. This is the same ID that is in the parent. 60 | - `elementType`: the model API full class path. Used to uniquely identify the type of element. 61 | - `message`: the linting validation message returned by the linting failure. 62 | - `code`: the linting validation code returned by the linting failure. 63 | 64 | When configuring linting rules, you can set the `message` and `code` values per linting rule. 65 | 66 | ```json 67 | { 68 | "results": { 69 | "Task_1pjpqz0": [ 70 | { 71 | "type": "WARNING", 72 | "elementId": "Task_1pjpqz0", 73 | "elementType": "io.zeebe.model.bpmn.instance.MultiInstanceLoopCharacteristics", 74 | "message": "my linter warning", 75 | "code": 0 76 | } 77 | ] 78 | } 79 | } 80 | ``` 81 | 82 | ## Workflow Linter 83 | 84 | The workflow linter provides a linting/validation engine for BPMN workflows that are parsed by the BPMN Model API. 85 | 86 | The linter acts as a warning and error system allowing you to validate workflows during the modeling process, and you can 87 | implement the linter at deployment time to ensure only valid models are deployed into their respective environments. 88 | 89 | ### YAML based linting rules 90 | 91 | 1. Rules are additive. One rule cannot cancel out another rule. 92 | 1. Rules require a description, elementTypes, and a rule implementation. 93 | 1. Element Types: `ServiceTask`, `ReceiveTask`.... (more to come) 94 | 1. Rules can apply to multiple element types, and have various targeting rules. 95 | 1. the `Target` property defines "targeting rules" that applied to the rule. Targeting rules define when the rule should be applied for the specific Element Type. 96 | 1. See the User Task example below for common usage: "Only target ServiceTasks with a task type of `user-task`. This means the rule will only apply when a Service Task defines a type of `user-task` 97 | 98 | ```yaml 99 | 100 | workflow-linter: 101 | rules: 102 | global-rules: 103 | enable: false 104 | description: Global Restrictions for Service Task Types 105 | elementTypes: 106 | - ServiceTask 107 | serviceTaskRule: 108 | allowedTypes: 109 | - some-type 110 | - user-task 111 | 112 | user-task-rule: 113 | description: Specific rule for User Task Configuration of Service Tasks 114 | elementTypes: 115 | - ServiceTask 116 | target: 117 | serviceTasks: 118 | types: 119 | - user-task 120 | headerRule: 121 | requiredKeys: 122 | - title 123 | - candidateGroups 124 | - formKey 125 | allowedNonDefinedKeys: false 126 | allowedDuplicateKeys: false 127 | optionalKeys: 128 | - priority 129 | - assignee 130 | - candidateUsers 131 | - dueDate 132 | - description 133 | ``` 134 | 135 | #### Global Rules 136 | 137 | If the `target` property configuration is **not** provided, then the rule will be applied globally to all Element Types defined in the rule. 138 | 139 | Global rules can be a good way to implement some restrictions on your modeling teams to ensure that internal types and correlation keys are not used. 140 | 141 | Global rules can be valuable naming conventions as well: "task types cannot start with a underscore `_`." 142 | 143 | #### Element Type Rules (WIP) 144 | 145 | Working model is to have rule factories for each major Element Type (Service Task, Receive Task, Catch message, etc). 146 | 147 | Element Type Rules provide specific rule implementations that focus on the Element Type's configuration possibilities: 148 | 149 | 1. Service Task: 150 | 1. Allowed Types + regex limit 151 | 1. Allowed Retry Regex 152 | 1. Allowed IO Mappings 153 | 1. Allowed Headers (Required, Optional, duplicates, Key Value Pairs, Non-Defined Keys, etc) 154 | 1. Receive Task: 155 | 1. Allowed Correlation Keys 156 | 1. Correlation Key Restrictions (limit names allowed to be used + regex limit) 157 | 1. Out-mapping restrictions. 158 | 159 | 160 | #### Formatting Rules (WIP) 161 | 162 | The Linter is not just about execution implementation rules, you can also define formatting rules for the BPMN. 163 | 164 | Formatting rules enable you to prevent common errors in formatting of BPMN. 165 | 166 | 1. Label all Gateways 167 | 1. Pool Usage 168 | 1. Prevent label patterns with Regex 169 | 1. Gateways labels end in "?" 170 | 1. No Expressions 171 | 1. Double sequence flows 172 | 1. Sub-Process Names 173 | 1. Loop Characteristics naming 174 | 1. Start Timer names 175 | 1. Message Start Names 176 | 1. End Event Names 177 | 1. Intermediate Event Names 178 | 1. .. More? 179 | 180 | 181 | #### YAML based linting rules TODO 182 | 183 | 1. Add support to define what will prevent a deployment (WARNING / ERRORS) 184 | 1. Add optional parameter on deployment endpoint to validate using linter 185 | 1. Provide JSON error support 186 | 1. Provide Language Server implementation of linter. 187 | 1. Add IO Mappings rules 188 | 1. Add Receive Task Rule 189 | 1. Add Message Rules 190 | 1. Add Formatting rules 191 | 1. Add Timer rules to prevent certain spectrum of timer durations / cycles 192 | 1. Add targeting based on BPMN Process Key 193 | 1. Add special negating rule for Task Types and Correlation Keys 194 | 1. Add Allowed Call Activity Process IDs: Rule to ensure only specific Process IDs can be called through the Modeler. 195 | 1. Add error code and error message YAML config examples 196 | 197 | 198 | ### Kotlin-Script (.kts) based linting rules 199 | 200 | Kotlin Script `.kts` linting rules provide a scripting based approach to writing linting rules. 201 | 202 | Using the scripting approach provides rule writers the most flexibility and power, but requires more advanced skills compared to writing YAML based linting rules. 203 | 204 | By default the linter will look for a `./rules` folder and walk the folder (and any children folders) looking for .kts files. 205 | 206 | Kts files must return a ModelElementValidator or a list of ModelElementValidators. 207 | 208 | You can change the folder location with: 209 | 210 | ```yaml 211 | linter: 212 | kts: 213 | folder: "./rules123" 214 | ``` 215 | 216 | If you want to configure individual rule files, you can do so with: 217 | 218 | Configure the paths of your .kts files in the configuration: 219 | 220 | ```yaml 221 | linter: 222 | kts: 223 | rules: 224 | - ./src/main/resources/rules/rule1.kts 225 | - ./rules/TimerRules.kts 226 | ``` 227 | 228 | Configuring individual files will ignore any files in the rules folder. 229 | 230 | Each script will be compiled at runtime and converted into Element Validators. 231 | 232 | The following is a element validator example that checks that there are no timers that have a duration between 0 and 60 seconds. 233 | 234 | ```kotlin 235 | package rules 236 | 237 | import com.github.stephenott.workflowlinter.linter.elementValidator 238 | import com.github.stephenott.workflowlinter.linter.getDuration 239 | import io.zeebe.model.bpmn.instance.TimeDuration 240 | 241 | elementValidator { e, v -> 242 | if (e.getDuration().seconds in 0..60){ 243 | v.addError(60, "Timers Durations must be greater than 60 seconds.") 244 | } 245 | } 246 | ``` 247 | 248 | # Workflow Sanitizer 249 | 250 | Workflow Sanitizer is the capability to remove aspects of a BPMN that are internal configuration that should not be shared when allowing users to download BPMN xml (such as when rendering a BPMN in the bpmn.js / bpmn.io modeler). 251 | 252 | Every element in a BPMN can be replaced with a sanitized version: a sanitized version is a new blank instance of the element that replaces the original element. 253 | Only configurations that are explicitly desired in a sanitized BPMN are transferred over into the new instance. 254 | 255 | The Sanitizer provides flexible usage options depending on your sanitizing needs: 256 | 257 | The core of Sanitizer provides a Workflow Linter that allows you to configure which types that inherit from `ModelElementInstance` will be targeted for cleaning. 258 | The default Sanitizer Linter configuration targets all elements and applies a error code of `5000` ("it's Audi 5000"...). 259 | The default Sanitizer that actions each of the found elements, will take a lean approach: 260 | 261 | 1. element Id values are kept. This allows to continue targeting elements based on the IDs used in the BPMN's execution so you can do heatmaps, and BPMN status overlays (counts, what activities are currently failing, which ones have completed, loop counts, etc). 262 | 1. All names are kept (these are usually the labels/names on each element/task/gateway/sequence-flow/pool, etc) 263 | 1. All Annotations are kept. 264 | 1. All markings and definition types are kept: but only the fact that they exist; their actual configuration is removed. 265 | 1. Default Flow markings on sequence flows are kept. 266 | 1. `Process` elements are not modified. But their children would be modified as their are independent instances of `ModelElementInstance` 267 | 268 | What is explicitly not kept? 269 | 270 | 1. Expressions 271 | 1. Configurations on Events: message correlation keys, timer expressions, etc 272 | 1. Receive Task Message Correlation configurations. 273 | 1. Service Task Configurations: Headers, Type, Retries 274 | 1. IO Mappings 275 | 1. Loop Characteristic configurations (the "parallel" vs "sequential" marking is kept) 276 | 1. Message Elements (even if a message is not tied to a element ) 277 | 278 | 279 | ## Sanitizer Rest Endpoint 280 | 281 | POST localhost 282 | 283 | XML body and file upload options 284 | 285 | ```xml 286 | body example goes here 287 | ``` 288 | 289 | Response 290 | 291 | ```xml 292 | XML response goes here 293 | ``` 294 | 295 | 296 | # OpenAPI/Swagger Documentation 297 | 298 | see openapi docs folder for generated api docs. @TODO 299 | 300 | 301 | # Robust Linter Response Example 302 | 303 | 304 | This is a robust example to demonstrate the linting response capabilities and level of linting rules that can be created. 305 | 306 | ```json 307 | { 308 | "results": { 309 | "Task_1pjpqz0": [ 310 | { 311 | "type": "WARNING", 312 | "elementId": "Task_1pjpqz0", 313 | "elementType": "io.zeebe.model.bpmn.instance.MultiInstanceLoopCharacteristics", 314 | "message": "my linter warning", 315 | "code": 0 316 | }, 317 | { 318 | "type": "WARNING", 319 | "elementId": "Task_1pjpqz0", 320 | "elementType": "io.zeebe.model.bpmn.instance.ServiceTask", 321 | "message": "my linter warning", 322 | "code": 0 323 | } 324 | ], 325 | "TextAnnotation_1t2spfv": [ 326 | { 327 | "type": "WARNING", 328 | "elementId": "TextAnnotation_1t2spfv", 329 | "elementType": "io.zeebe.model.bpmn.instance.TextAnnotation", 330 | "message": "my linter warning", 331 | "code": 0 332 | } 333 | ], 334 | "Process_16i55l0": [ 335 | { 336 | "type": "WARNING", 337 | "elementId": "Process_16i55l0", 338 | "elementType": "io.zeebe.model.bpmn.instance.Lane", 339 | "message": "my linter warning", 340 | "code": 0 341 | }, 342 | { 343 | "type": "WARNING", 344 | "elementId": "Process_16i55l0", 345 | "elementType": "io.zeebe.model.bpmn.instance.Lane", 346 | "message": "my linter warning", 347 | "code": 0 348 | }, 349 | { 350 | "type": "WARNING", 351 | "elementId": "Process_16i55l0", 352 | "elementType": "io.zeebe.model.bpmn.instance.LaneSet", 353 | "message": "my linter warning", 354 | "code": 0 355 | }, 356 | { 357 | "type": "WARNING", 358 | "elementId": "Process_16i55l0", 359 | "elementType": "io.zeebe.model.bpmn.instance.Process", 360 | "message": "my linter warning", 361 | "code": 0 362 | } 363 | ], 364 | "IntermediateThrowEvent_1jlnvjm": [ 365 | { 366 | "type": "WARNING", 367 | "elementId": "IntermediateThrowEvent_1jlnvjm", 368 | "elementType": "io.zeebe.model.bpmn.instance.BoundaryEvent", 369 | "message": "my linter warning", 370 | "code": 0 371 | }, 372 | { 373 | "type": "WARNING", 374 | "elementId": "IntermediateThrowEvent_1jlnvjm", 375 | "elementType": "io.zeebe.model.bpmn.instance.MessageEventDefinition", 376 | "message": "my linter warning", 377 | "code": 0 378 | } 379 | ], 380 | "SequenceFlow_0bjdega": [ 381 | { 382 | "type": "WARNING", 383 | "elementId": "SequenceFlow_0bjdega", 384 | "elementType": "io.zeebe.model.bpmn.instance.SequenceFlow", 385 | "message": "my linter warning", 386 | "code": 0 387 | } 388 | ], 389 | "EndEvent_1qai5bq": [ 390 | { 391 | "type": "WARNING", 392 | "elementId": "EndEvent_1qai5bq", 393 | "elementType": "io.zeebe.model.bpmn.instance.EndEvent", 394 | "message": "my linter warning", 395 | "code": 0 396 | } 397 | ], 398 | "IntermediateThrowEvent_0pftlc9": [ 399 | { 400 | "type": "WARNING", 401 | "elementId": "IntermediateThrowEvent_0pftlc9", 402 | "elementType": "io.zeebe.model.bpmn.instance.TimerEventDefinition", 403 | "message": "my linter warning", 404 | "code": 0 405 | }, 406 | { 407 | "type": "WARNING", 408 | "elementId": "IntermediateThrowEvent_0pftlc9", 409 | "elementType": "io.zeebe.model.bpmn.instance.BoundaryEvent", 410 | "message": "my linter warning", 411 | "code": 0 412 | } 413 | ], 414 | "IntermediateThrowEvent_0ue7kjs": [ 415 | { 416 | "type": "WARNING", 417 | "elementId": "IntermediateThrowEvent_0ue7kjs", 418 | "elementType": "io.zeebe.model.bpmn.instance.BoundaryEvent", 419 | "message": "my linter warning", 420 | "code": 0 421 | }, 422 | { 423 | "type": "WARNING", 424 | "elementId": "IntermediateThrowEvent_0ue7kjs", 425 | "elementType": "io.zeebe.model.bpmn.instance.MessageEventDefinition", 426 | "message": "my linter warning", 427 | "code": 0 428 | } 429 | ], 430 | "Task_05r7ocx": [ 431 | { 432 | "type": "WARNING", 433 | "elementId": "Task_05r7ocx", 434 | "elementType": "io.zeebe.model.bpmn.instance.MultiInstanceLoopCharacteristics", 435 | "message": "my linter warning", 436 | "code": 0 437 | }, 438 | { 439 | "type": "WARNING", 440 | "elementId": "Task_05r7ocx", 441 | "elementType": "io.zeebe.model.bpmn.instance.Task", 442 | "message": "my linter warning", 443 | "code": 0 444 | } 445 | ], 446 | "Collaboration_1xisnek": [ 447 | { 448 | "type": "WARNING", 449 | "elementId": "Collaboration_1xisnek", 450 | "elementType": "io.zeebe.model.bpmn.instance.Participant", 451 | "message": "my linter warning", 452 | "code": 0 453 | }, 454 | { 455 | "type": "WARNING", 456 | "elementId": "Collaboration_1xisnek", 457 | "elementType": "io.zeebe.model.bpmn.instance.Collaboration", 458 | "message": "my linter warning", 459 | "code": 0 460 | }, 461 | { 462 | "type": "WARNING", 463 | "elementId": "Collaboration_1xisnek", 464 | "elementType": "io.zeebe.model.bpmn.instance.Participant", 465 | "message": "my linter warning", 466 | "code": 0 467 | } 468 | ], 469 | "Task_10ugd34": [ 470 | { 471 | "type": "WARNING", 472 | "elementId": "Task_10ugd34", 473 | "elementType": "io.zeebe.model.bpmn.instance.CallActivity", 474 | "message": "my linter warning", 475 | "code": 0 476 | }, 477 | { 478 | "type": "WARNING", 479 | "elementId": "Task_10ugd34", 480 | "elementType": "io.zeebe.model.bpmn.instance.MultiInstanceLoopCharacteristics", 481 | "message": "my linter warning", 482 | "code": 0 483 | } 484 | ], 485 | "EndEvent_1m14ym9": [ 486 | { 487 | "type": "WARNING", 488 | "elementId": "EndEvent_1m14ym9", 489 | "elementType": "io.zeebe.model.bpmn.instance.EndEvent", 490 | "message": "my linter warning", 491 | "code": 0 492 | } 493 | ], 494 | "Process_1xanqu0": [ 495 | { 496 | "type": "WARNING", 497 | "elementId": "Process_1xanqu0", 498 | "elementType": "io.zeebe.model.bpmn.instance.Process", 499 | "message": "my linter warning", 500 | "code": 0 501 | } 502 | ], 503 | "IntermediateThrowEvent_0rqamn8": [ 504 | { 505 | "type": "WARNING", 506 | "elementId": "IntermediateThrowEvent_0rqamn8", 507 | "elementType": "io.zeebe.model.bpmn.instance.ErrorEventDefinition", 508 | "message": "my linter warning", 509 | "code": 0 510 | }, 511 | { 512 | "type": "WARNING", 513 | "elementId": "IntermediateThrowEvent_0rqamn8", 514 | "elementType": "io.zeebe.model.bpmn.instance.BoundaryEvent", 515 | "message": "my linter warning", 516 | "code": 0 517 | } 518 | ], 519 | "IntermediateThrowEvent_1c9t58w": [ 520 | { 521 | "type": "WARNING", 522 | "elementId": "IntermediateThrowEvent_1c9t58w", 523 | "elementType": "io.zeebe.model.bpmn.instance.TimerEventDefinition", 524 | "message": "my linter warning", 525 | "code": 0 526 | }, 527 | { 528 | "type": "WARNING", 529 | "elementId": "IntermediateThrowEvent_1c9t58w", 530 | "elementType": "io.zeebe.model.bpmn.instance.BoundaryEvent", 531 | "message": "my linter warning", 532 | "code": 0 533 | } 534 | ], 535 | "SequenceFlow_1fetaup": [ 536 | { 537 | "type": "WARNING", 538 | "elementId": "SequenceFlow_1fetaup", 539 | "elementType": "io.zeebe.model.bpmn.instance.SequenceFlow", 540 | "message": "my linter warning", 541 | "code": 0 542 | } 543 | ], 544 | "SequenceFlow_1hnbx9h": [ 545 | { 546 | "type": "WARNING", 547 | "elementId": "SequenceFlow_1hnbx9h", 548 | "elementType": "io.zeebe.model.bpmn.instance.SequenceFlow", 549 | "message": "my linter warning", 550 | "code": 0 551 | } 552 | ], 553 | "ServiceTask_1luzsfd": [ 554 | { 555 | "type": "WARNING", 556 | "elementId": "ServiceTask_1luzsfd", 557 | "elementType": "io.zeebe.model.bpmn.instance.ServiceTask", 558 | "message": "my linter warning", 559 | "code": 0 560 | } 561 | ], 562 | "TextAnnotation_1qi6dhp": [ 563 | { 564 | "type": "WARNING", 565 | "elementId": "TextAnnotation_1qi6dhp", 566 | "elementType": "io.zeebe.model.bpmn.instance.TextAnnotation", 567 | "message": "my linter warning", 568 | "code": 0 569 | } 570 | ], 571 | "ExclusiveGateway_0zw2nt1": [ 572 | { 573 | "type": "WARNING", 574 | "elementId": "ExclusiveGateway_0zw2nt1", 575 | "elementType": "io.zeebe.model.bpmn.instance.ExclusiveGateway", 576 | "message": "my linter warning", 577 | "code": 0 578 | } 579 | ], 580 | "SubProcess_0th6hv3": [ 581 | { 582 | "type": "WARNING", 583 | "elementId": "SubProcess_0th6hv3", 584 | "elementType": "io.zeebe.model.bpmn.instance.SubProcess", 585 | "message": "my linter warning", 586 | "code": 0 587 | } 588 | ], 589 | "SequenceFlow_135m3sa": [ 590 | { 591 | "type": "WARNING", 592 | "elementId": "SequenceFlow_135m3sa", 593 | "elementType": "io.zeebe.model.bpmn.instance.SequenceFlow", 594 | "message": "my linter warning", 595 | "code": 0 596 | } 597 | ], 598 | "Association_1oysd0g": [ 599 | { 600 | "type": "WARNING", 601 | "elementId": "Association_1oysd0g", 602 | "elementType": "io.zeebe.model.bpmn.instance.Association", 603 | "message": "my linter warning", 604 | "code": 0 605 | } 606 | ], 607 | "StartEvent_1": [ 608 | { 609 | "type": "WARNING", 610 | "elementId": "StartEvent_1", 611 | "elementType": "io.zeebe.model.bpmn.instance.StartEvent", 612 | "message": "my linter warning", 613 | "code": 0 614 | } 615 | ], 616 | "Task_1cl9hr3": [ 617 | { 618 | "type": "WARNING", 619 | "elementId": "Task_1cl9hr3", 620 | "elementType": "io.zeebe.model.bpmn.instance.ServiceTask", 621 | "message": "my linter warning", 622 | "code": 0 623 | } 624 | ], 625 | "Task_00znhpl": [ 626 | { 627 | "type": "WARNING", 628 | "elementId": "Task_00znhpl", 629 | "elementType": "io.zeebe.model.bpmn.instance.MultiInstanceLoopCharacteristics", 630 | "message": "my linter warning", 631 | "code": 0 632 | }, 633 | { 634 | "type": "WARNING", 635 | "elementId": "Task_00znhpl", 636 | "elementType": "io.zeebe.model.bpmn.instance.ReceiveTask", 637 | "message": "my linter warning", 638 | "code": 0 639 | } 640 | ], 641 | "SequenceFlow_1uts8z1": [ 642 | { 643 | "type": "WARNING", 644 | "elementId": "SequenceFlow_1uts8z1", 645 | "elementType": "io.zeebe.model.bpmn.instance.SequenceFlow", 646 | "message": "my linter warning", 647 | "code": 0 648 | } 649 | ], 650 | "Association_0nb28zy": [ 651 | { 652 | "type": "WARNING", 653 | "elementId": "Association_0nb28zy", 654 | "elementType": "io.zeebe.model.bpmn.instance.Association", 655 | "message": "my linter warning", 656 | "code": 0 657 | } 658 | ], 659 | "SequenceFlow_15yu6zp": [ 660 | { 661 | "type": "WARNING", 662 | "elementId": "SequenceFlow_15yu6zp", 663 | "elementType": "io.zeebe.model.bpmn.instance.SequenceFlow", 664 | "message": "my linter warning", 665 | "code": 0 666 | } 667 | ], 668 | "SequenceFlow_1edsqdq": [ 669 | { 670 | "type": "WARNING", 671 | "elementId": "SequenceFlow_1edsqdq", 672 | "elementType": "io.zeebe.model.bpmn.instance.SequenceFlow", 673 | "message": "my linter warning", 674 | "code": 0 675 | } 676 | ], 677 | "Message_1wmcbh6": [ 678 | { 679 | "type": "WARNING", 680 | "elementId": "Message_1wmcbh6", 681 | "elementType": "io.zeebe.model.bpmn.instance.Message", 682 | "message": "my linter warning", 683 | "code": 0 684 | } 685 | ], 686 | "StartEvent_1rxkj6g": [ 687 | { 688 | "type": "WARNING", 689 | "elementId": "StartEvent_1rxkj6g", 690 | "elementType": "io.zeebe.model.bpmn.instance.StartEvent", 691 | "message": "my linter warning", 692 | "code": 0 693 | } 694 | ], 695 | "MessageFlow_0sslgzl": [ 696 | { 697 | "type": "WARNING", 698 | "elementId": "MessageFlow_0sslgzl", 699 | "elementType": "io.zeebe.model.bpmn.instance.MessageFlow", 700 | "message": "my linter warning", 701 | "code": 0 702 | } 703 | ], 704 | "SequenceFlow_1oknyaf": [ 705 | { 706 | "type": "WARNING", 707 | "elementId": "SequenceFlow_1oknyaf", 708 | "elementType": "io.zeebe.model.bpmn.instance.SequenceFlow", 709 | "message": "my linter warning", 710 | "code": 0 711 | } 712 | ] 713 | } 714 | } 715 | 716 | ``` 717 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.jetbrains.kotlin.jvm" version "1.3.50" 3 | id "org.jetbrains.kotlin.kapt" version "1.3.50" 4 | id "org.jetbrains.kotlin.plugin.allopen" version "1.3.50" 5 | id "com.github.johnrengelman.shadow" version "5.0.0" 6 | id "application" 7 | } 8 | 9 | 10 | 11 | version "0.8" 12 | group "com.github.stephenott.workflow-linter" 13 | 14 | repositories { 15 | mavenCentral() 16 | maven { url "https://jcenter.bintray.com" } 17 | } 18 | 19 | configurations { 20 | // for dependencies that are needed for development only 21 | developmentOnly 22 | } 23 | 24 | dependencies { 25 | implementation platform("io.micronaut:micronaut-bom:$micronautVersion") 26 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlinVersion}" 27 | implementation "org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}" 28 | 29 | implementation "org.jetbrains.kotlin:kotlin-script-runtime:${kotlinVersion}" 30 | runtime "org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:${kotlinVersion}" 31 | implementation "org.jetbrains.kotlin:kotlin-compiler-embeddable:${kotlinVersion}" 32 | implementation "org.jetbrains.kotlin:kotlin-script-util:${kotlinVersion}" 33 | 34 | 35 | implementation "io.micronaut:micronaut-runtime" 36 | implementation("io.reactivex.rxjava2:rxkotlin:2.4.0") 37 | 38 | implementation "io.micronaut:micronaut-http-server-netty" 39 | implementation 'io.micronaut:micronaut-validation' 40 | 41 | kapt platform("io.micronaut:micronaut-bom:$micronautVersion") 42 | kapt "io.micronaut:micronaut-inject-java" 43 | kapt "io.micronaut:micronaut-validation" 44 | kaptTest platform("io.micronaut:micronaut-bom:$micronautVersion") 45 | kaptTest "io.micronaut:micronaut-inject-java" 46 | implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.9.10" 47 | runtimeOnly "ch.qos.logback:logback-classic:1.2.3" 48 | testImplementation platform("io.micronaut:micronaut-bom:$micronautVersion") 49 | testImplementation "io.micronaut.test:micronaut-test-kotlintest" 50 | testImplementation "io.mockk:mockk:1.9.3" 51 | testImplementation "io.kotlintest:kotlintest-runner-junit5:3.3.2" 52 | 53 | 54 | implementation "io.zeebe:zeebe-client-java:$zeebeVersion" 55 | 56 | kapt "io.micronaut.configuration:micronaut-openapi" 57 | implementation "io.swagger.core.v3:swagger-annotations" 58 | 59 | 60 | } 61 | 62 | test.classpath += configurations.developmentOnly 63 | 64 | mainClassName = "com.github.stephenott.workflowlinter.Application" 65 | 66 | test { 67 | useJUnitPlatform() 68 | } 69 | 70 | allOpen { 71 | annotation("io.micronaut.aop.Around") 72 | } 73 | 74 | compileKotlin { 75 | kotlinOptions { 76 | jvmTarget = '1.8' 77 | //Will retain parameter names for Java reflection 78 | javaParameters = true 79 | } 80 | } 81 | 82 | compileTestKotlin { 83 | kotlinOptions { 84 | jvmTarget = '1.8' 85 | javaParameters = true 86 | } 87 | } 88 | 89 | shadowJar { 90 | mergeServiceFiles() 91 | } 92 | 93 | run.classpath += configurations.developmentOnly 94 | run.jvmArgs('-noverify', '-XX:TieredStopAtLevel=1', '-Dcom.sun.management.jmxremote') 95 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM adoptopenjdk/openjdk11-openj9:jdk-11.0.1.13-alpine-slim 2 | COPY build/libs/workflow-linter-*-all.jar workflow-linter.jar 3 | EXPOSE 8080 4 | CMD java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Dcom.sun.management.jmxremote -noverify ${JAVA_OPTS} -jar workflow-linter.jar 5 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | micronautVersion=1.2.6 2 | kotlinVersion=1.3.50 3 | zeebeVersion=0.21.1 -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephenOTT/Workflow-Linter/bafe0b58270b47194d114a4d1c28a07b818ffe73/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Nov 22 16:34:48 EST 2019 2 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.3-all.zip 3 | distributionBase=GRADLE_USER_HOME 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /micronaut-cli.yml: -------------------------------------------------------------------------------- 1 | profile: service 2 | defaultPackage: workflowlinter 3 | --- 4 | testFramework: kotlintest 5 | sourceLanguage: kotlin -------------------------------------------------------------------------------- /rules/TimerRules.kts: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import com.github.stephenott.workflowlinter.linter.elementValidator 4 | import com.github.stephenott.workflowlinter.linter.getDuration 5 | import io.zeebe.model.bpmn.instance.TimeDuration 6 | 7 | listOf( 8 | elementValidator { element, validator -> 9 | if (element.textContent.isNullOrBlank()) { 10 | validator.addError(24, "Missing timer configuration.") 11 | } 12 | }, 13 | 14 | elementValidator { element, validator -> 15 | if (element.getDuration().seconds == 0L) { 16 | validator.addError(12, "A timer configuration cannot be set to equal a duration (or equivalent) of zero seconds.") 17 | } 18 | } 19 | ) -------------------------------------------------------------------------------- /rules/UserTaskRule.kts: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import com.github.stephenott.workflowlinter.linter.* 4 | import io.zeebe.model.bpmn.instance.ServiceTask 5 | 6 | val requiredUserTaskHeaders = listOf( 7 | "title", 8 | "candidateGroups", 9 | "formKey" 10 | ) 11 | 12 | val optionalUserTaskHeaders = listOf( 13 | "priority", 14 | "assignee", 15 | "candidateUsers", 16 | "dueDate", 17 | "description" 18 | ) 19 | 20 | elementValidator { e, v -> 21 | 22 | e.getZeebeTaskDefinition()?.let { taskDef -> 23 | if (taskDef.type == "user-task") { 24 | 25 | e.getZeebeTaskHeaders()?.let { 26 | if (!it.hasRequiredKeys(requiredUserTaskHeaders)){ 27 | v.addError(1, "Missing one or more required headers for a user-task type: required headers: $requiredUserTaskHeaders") 28 | } 29 | 30 | if (!it.hasOptionalAndRequiredKeys(requiredUserTaskHeaders + optionalUserTaskHeaders)){ 31 | v.addError(1, "Only required ($requiredUserTaskHeaders) and optional ($optionalUserTaskHeaders) headers are allowed for user-task type.") 32 | } 33 | 34 | if (!it.noDuplicateKeys()) { 35 | v.addError(1, "Duplicate header keys are not allowed.") 36 | } 37 | 38 | } ?: v.addError(1, "Missing headers for user-task") 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /rules/rule1.kts: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import com.github.stephenott.workflowlinter.linter.elementValidator 4 | import io.zeebe.model.bpmn.instance.BaseElement 5 | 6 | elementValidator { e, v -> 7 | v.addWarning(0, "hahah") 8 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name="workflow-linter" -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/Application.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter 2 | 3 | import io.micronaut.runtime.Micronaut 4 | 5 | object Application { 6 | 7 | @JvmStatic 8 | fun main(args: Array) { 9 | Micronaut.build() 10 | .packages("com.github.stephenott.workflowlinter") 11 | .mainClass(Application.javaClass) 12 | .start() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/cleaner/Cleaner.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.cleaner 2 | 3 | import io.zeebe.model.bpmn.Bpmn 4 | import io.zeebe.model.bpmn.BpmnModelInstance 5 | import io.zeebe.model.bpmn.instance.* 6 | import org.camunda.bpm.model.xml.validation.ModelElementValidator 7 | import java.io.File 8 | import kotlin.reflect.full.isSubclassOf 9 | 10 | /** 11 | * Cleaner will provide capability to clean sections of a BPMN that throw a specific error 12 | * 13 | * Cleaning means to replace a Element with a new instance of the element, but with zero configuration / a blank instance of the element 14 | */ 15 | class Cleaner(private val validators: List>, 16 | private val CLEANER_CODE: Int = 5000) { 17 | 18 | fun cleanModel(bpmnFile: File): BpmnModelInstance { 19 | return cleanModel(Bpmn.readModelFromFile(bpmnFile)) 20 | } 21 | 22 | fun cleanModel(model: BpmnModelInstance): BpmnModelInstance { 23 | model.validate(validators).results 24 | .forEach { (element, results) -> 25 | //@TODO add support for Gateway/sequence flow support to keep configs for default 26 | // println(element.elementType.instanceType) 27 | // println((element as BaseElement).id) 28 | // println("NEW ELEMENT") 29 | results.filter { it.code == CLEANER_CODE }.forEach cleaners@{ eResult -> 30 | val newInstance = eResult.element.modelInstance.newInstance(eResult.element.elementType, (eResult.element as BaseElement).id) 31 | 32 | if (eResult.element.elementType.instanceType == Process::class.java) { 33 | return@cleaners 34 | } 35 | 36 | if (eResult.element.elementType.instanceType.kotlin.isSubclassOf(Expression::class)) { 37 | return@cleaners 38 | } 39 | 40 | if (eResult.element.elementType.instanceType == Collaboration::class.java) { 41 | return@cleaners 42 | } 43 | 44 | if (eResult.element.elementType.instanceType == MultiInstanceLoopCharacteristics::class.java) { 45 | return@cleaners 46 | } 47 | 48 | if (eResult.element.elementType.instanceType.kotlin.isSubclassOf(EventDefinition::class)) { 49 | return@cleaners 50 | } 51 | 52 | if (eResult.element.elementType.instanceType.kotlin == CategoryValue::class) { 53 | (newInstance as CategoryValue).value = (eResult.element as CategoryValue).value 54 | } 55 | 56 | if (eResult.element.elementType.instanceType.kotlin == LaneSet::class) { 57 | (newInstance as LaneSet).lanes.addAll((eResult.element as LaneSet).lanes) 58 | } 59 | 60 | if (eResult.element.elementType.instanceType.kotlin == Lane::class) { 61 | (newInstance as Lane).flowNodeRefs.addAll((eResult.element as Lane).flowNodeRefs) 62 | } 63 | 64 | if (eResult.element.elementType.instanceType.kotlin == Participant::class) { 65 | //@TODO review how to do this without using the attributeValue processRef, and use proper typing. 66 | // unclear on how to create a "process" and link it based on the processRef attribute. 67 | if ((eResult.element as Participant).getAttributeValue("processRef") != null){ 68 | (newInstance as Participant).setAttributeValue("processRef", (eResult.element as Participant).getAttributeValue("processRef")) 69 | } 70 | } 71 | 72 | if (eResult.element.elementType.instanceType.kotlin.isSubclassOf(BoundaryEvent::class)) { 73 | (newInstance as BoundaryEvent).attachedTo = (eResult.element as BoundaryEvent).attachedTo 74 | (newInstance as BoundaryEvent).setCancelActivity((eResult.element as BoundaryEvent).cancelActivity()) 75 | 76 | (eResult.element as BoundaryEvent).eventDefinitions.forEach { 77 | val newEventInstance: EventDefinition = eResult.element.modelInstance.newInstance(it.elementType, it.id) 78 | newInstance.addChildElement(newEventInstance) 79 | } 80 | } 81 | 82 | if (eResult.element.elementType.instanceType.kotlin.isSubclassOf(Activity::class)) { 83 | 84 | val loopChars = (eResult.element as Activity).getChildElementsByType(MultiInstanceLoopCharacteristics::class.java) 85 | 86 | if (loopChars.isNotEmpty()) { 87 | check(loopChars.size == 1, lazyMessage = { "Bad BPMN...MultiInstanceLoopCharacteristics found more than 1 configuration..." }) 88 | val newLoopChars = eResult.element.modelInstance.newInstance(MultiInstanceLoopCharacteristics::class.java, loopChars.single().id) 89 | newLoopChars.isSequential = loopChars.single().isSequential 90 | newInstance.addChildElement(newLoopChars) 91 | } 92 | } 93 | 94 | if (eResult.element.elementType.instanceType == SequenceFlow::class.java) { 95 | (newInstance as SequenceFlow).source = (eResult.element as SequenceFlow).source 96 | (newInstance as SequenceFlow).target = (eResult.element as SequenceFlow).target 97 | } 98 | 99 | if (eResult.element.elementType.instanceType == MessageFlow::class.java) { 100 | (newInstance as MessageFlow).source = (eResult.element as MessageFlow).source 101 | (newInstance as MessageFlow).target = (eResult.element as MessageFlow).target 102 | } 103 | 104 | if (eResult.element.elementType.instanceType == Association::class.java) { 105 | (newInstance as Association).source = (eResult.element as Association).source 106 | (newInstance as Association).target = (eResult.element as Association).target 107 | (newInstance as Association).associationDirection = (eResult.element as Association).associationDirection 108 | } 109 | 110 | if (eResult.element.elementType.instanceType == TextAnnotation::class.java) { 111 | (newInstance as TextAnnotation).text = (eResult.element as TextAnnotation).text 112 | (newInstance as TextAnnotation).textFormat = (eResult.element as TextAnnotation).textFormat 113 | } 114 | 115 | if (eResult.element.elementType.instanceType.kotlin.isSubclassOf(Gateway::class)) { 116 | val defaultAttributeValue = "default" 117 | // println("DEFAULT: ${eResult.element.getAttributeValue("default")}") 118 | if (eResult.element.getAttributeValue(defaultAttributeValue) != null) { 119 | newInstance.setAttributeValue(defaultAttributeValue, eResult.element.getAttributeValue(defaultAttributeValue)) 120 | } 121 | } 122 | 123 | if (eResult.element.getAttributeValue("name") != null) { 124 | newInstance.setAttributeValue("name", eResult.element.getAttributeValue("name")) 125 | } 126 | 127 | when (element.elementType.instanceType.kotlin) { 128 | SubProcess::class -> { 129 | val elements = (element as SubProcess).flowElements 130 | elements.forEach { newInstance.addChildElement(it) } 131 | element.parentElement.replaceChildElement(element, newInstance) 132 | } 133 | Message::class -> { 134 | // This ensures the Message is actually removed from the BPMN. 135 | // If you remove all instances of usage of the Message (such as on Receive Tasks and Message Catch Events), the Message element is still present in the BPMN xml. 136 | element.parentElement.removeChildElement(element) 137 | } 138 | else -> { 139 | element.parentElement.replaceChildElement(element, newInstance) 140 | } 141 | } 142 | } 143 | } 144 | return model 145 | } 146 | 147 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/controller/Helpers.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.controller 2 | 3 | import com.github.stephenott.workflowlinter.linter.ValidationResult 4 | import com.github.stephenott.workflowlinter.linter.WorkflowLinter 5 | import com.github.stephenott.workflowlinter.linter.kts.LinterServerRulesFromKts 6 | import io.reactivex.Flowable 7 | import io.reactivex.Single 8 | import io.zeebe.model.bpmn.instance.* 9 | import org.camunda.bpm.model.xml.instance.ModelElementInstance 10 | import org.camunda.bpm.model.xml.validation.ValidationResults 11 | import java.io.InputStream 12 | import java.util.* 13 | import kotlin.reflect.full.isSubclassOf 14 | 15 | object Helpers { 16 | fun lint(workflow: InputStream, rules: LinterServerRulesFromKts): ValidationResults { 17 | return WorkflowLinter(workflow).lintWithValidators(rules.validators()) 18 | } 19 | 20 | fun processLintResults(results: ValidationResults): com.github.stephenott.workflowlinter.linter.ValidationResults { 21 | return com.github.stephenott.workflowlinter.linter.ValidationResults( 22 | results.results 23 | .flatMap { it.value } 24 | .map { 25 | ValidationResult(it) 26 | } 27 | .groupBy({ it.elementId }, { it }) 28 | ) 29 | } 30 | 31 | tailrec fun getElementWithNameAttribute(instance: ModelElementInstance): ModelElementInstance = 32 | if ((instance.elementType.instanceType.kotlin in 33 | listOf(Process::class, 34 | Collaboration::class, 35 | Message::class, 36 | MessageFlow::class) || 37 | (instance.elementType.instanceType.kotlin.isSubclassOf(FlowElement::class) 38 | || instance.elementType.instanceType.kotlin.isSubclassOf(Artifact::class)) 39 | ) 40 | && instance.getAttributeValue("id") != null) instance 41 | else getElementWithNameAttribute(instance.parentElement) 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/controller/LinterController.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.controller 2 | 3 | import com.github.stephenott.workflowlinter.controller.Helpers.lint 4 | import com.github.stephenott.workflowlinter.controller.Helpers.processLintResults 5 | import com.github.stephenott.workflowlinter.linter.kts.LinterServerRulesFromKts 6 | import io.micronaut.http.HttpRequest 7 | import io.micronaut.http.HttpResponse 8 | import io.micronaut.http.MediaType 9 | import io.micronaut.http.annotation.Controller 10 | import io.micronaut.http.annotation.Error 11 | import io.micronaut.http.annotation.Post 12 | import io.micronaut.http.multipart.CompletedFileUpload 13 | import io.reactivex.Single 14 | import io.swagger.v3.oas.annotations.OpenAPIDefinition 15 | import io.swagger.v3.oas.annotations.info.Contact 16 | import io.swagger.v3.oas.annotations.info.Info 17 | import io.swagger.v3.oas.annotations.info.License 18 | import java.io.ByteArrayInputStream 19 | import javax.inject.Inject 20 | 21 | @OpenAPIDefinition( 22 | info = Info( 23 | title = "Workflow Linter Server", 24 | version = "0.5", 25 | description = "Workflow Linting Server for BPMN Workflows.", 26 | license = License(name = "", url = "http://github.com/stephenott/workflow-linter"), 27 | contact = Contact(url = "http://github.com/stephenott", name = "Stephen Russett", email = "http://github.com/stephenott") 28 | ) 29 | ) 30 | @Controller("/workflow/bpmn/linter") 31 | class LinterController(){ 32 | 33 | @Inject 34 | lateinit var rules: LinterServerRulesFromKts 35 | 36 | @Post(value = "/", consumes = [MediaType.MULTIPART_FORM_DATA]) 37 | fun upload(file: CompletedFileUpload): Single>> { 38 | return Single.fromCallable { 39 | lint(file.inputStream, rules) 40 | }.map { 41 | processLintResults(it) 42 | }.onErrorResumeNext { 43 | Single.error(IllegalArgumentException(it)) //@TODO crate custom error 44 | }.map { 45 | HttpResponse.ok(LinterResponse(it)) 46 | } 47 | } 48 | 49 | @Post(value = "/", consumes = [MediaType.APPLICATION_XML]) 50 | fun xmlSubmit(xml: Single): Single>> { 51 | return xml.map { 52 | lint(it.byteInputStream(), rules) 53 | }.map { 54 | processLintResults(it) 55 | }.onErrorResumeNext { 56 | Single.error(IllegalArgumentException(it)) //@TODO crate custom error 57 | }.map { 58 | HttpResponse.ok(LinterResponse(it)) 59 | } 60 | } 61 | 62 | @Error 63 | fun lintingError(request: HttpRequest<*>, exception: Exception): HttpResponse { 64 | exception.printStackTrace() 65 | return HttpResponse.badRequest(LinterResponseError(exception.message ?: "Internal Error Occurred")) 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/controller/ValidationResponses.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.controller 2 | 3 | import com.fasterxml.jackson.annotation.JsonUnwrapped 4 | 5 | 6 | data class LinterResponse(@JsonUnwrapped val result: T) {} 7 | 8 | data class LinterResponseError(val message: String) {} 9 | 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/linter/CommonLinterRules.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.linter 2 | 3 | import io.zeebe.model.bpmn.instance.ServiceTask 4 | import org.camunda.bpm.model.xml.instance.ModelElementInstance 5 | import org.camunda.bpm.model.xml.validation.ModelElementValidator 6 | import kotlin.reflect.KClass 7 | 8 | /** 9 | * Common Linting rules that are typically processors for larger chunk of rules that common from common sources such as YAML config of linting rules 10 | */ 11 | object CommonLinterRules { 12 | fun processRequiredHeadersRule(element: KClass, requiredHeaders: List, targeting: LinterRule.TargetRule?): ModelElementValidator?{ 13 | if (element != ServiceTask::class){ 14 | return null 15 | } else if (requiredHeaders.isNullOrEmpty()){ 16 | return null 17 | } 18 | 19 | return elementValidator(element){e, v -> 20 | val sTask = e as ServiceTask 21 | if (targeting == null || targeting.serviceTasks?.types!!.contains(sTask.getZeebeTaskDefinition()?.type)){ 22 | if (sTask.getZeebeTaskHeaders()?.hasRequiredKeys(requiredHeaders) == false){ 23 | v.addError(0, "Missing Required Headers: $requiredHeaders") 24 | } 25 | } 26 | } 27 | } 28 | 29 | fun processOptionalHeadersRule(element: KClass, optionalHeaders: List, requiredHeaders: List, targeting: LinterRule.TargetRule?): ModelElementValidator?{ 30 | if (element != ServiceTask::class){ 31 | return null 32 | } else if (requiredHeaders.isNullOrEmpty()){ 33 | return null 34 | } 35 | 36 | val mergedList: List = optionalHeaders + requiredHeaders 37 | 38 | return elementValidator(element){e, v -> 39 | val sTask = e as ServiceTask 40 | if (targeting == null || targeting.serviceTasks?.types!!.contains(sTask.getZeebeTaskDefinition()?.type)){ 41 | if (sTask.getZeebeTaskHeaders()?.hasOptionalAndRequiredKeys(mergedList) == false){ 42 | v.addError(0, "Found headers that are not part of Optional Headers list: $optionalHeaders") 43 | } 44 | } 45 | } 46 | } 47 | 48 | fun processDuplicateHeaderKeysRule(element: KClass, targeting: LinterRule.TargetRule?): ModelElementValidator?{ 49 | if (element != ServiceTask::class){ 50 | return null 51 | } 52 | 53 | return elementValidator(element){e, v -> 54 | val sTask = e as ServiceTask 55 | if (targeting == null || targeting.serviceTasks?.types!!.contains(sTask.getZeebeTaskDefinition()?.type)) { 56 | if (sTask.getZeebeTaskHeaders()?.noDuplicateKeys() == false) { 57 | v.addError(0, "Duplicates Keys were detected") 58 | } 59 | } 60 | } 61 | } 62 | 63 | fun processServiceTaskAllowedTypesListRule(element: KClass, allowedTypes: List, targeting: LinterRule.TargetRule?): ModelElementValidator?{ 64 | if (element != ServiceTask::class){ 65 | return null 66 | } 67 | 68 | return elementValidator(element){e, v -> 69 | val sTask = e as ServiceTask 70 | sTask.getZeebeTaskDefinition()?.let { 71 | if (it.type !in allowedTypes){ 72 | v.addError(0, "Service Task Type ${it.type} is not in allowed types $allowedTypes") 73 | } 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/linter/ElementValidatorFactories.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.linter 2 | 3 | import org.camunda.bpm.model.xml.instance.ModelElementInstance 4 | import org.camunda.bpm.model.xml.validation.ModelElementValidator 5 | import org.camunda.bpm.model.xml.validation.ValidationResultCollector 6 | import kotlin.reflect.KClass 7 | 8 | inline fun elementValidator(clazz: KClass, crossinline validationLogic: (element: T, validatorResultCollector: ValidationResultCollector) -> Unit): ModelElementValidator{ 9 | return object:ModelElementValidator{ 10 | override fun validate(element: T, validationResultCollector: ValidationResultCollector) { 11 | validationLogic.invoke(element, validationResultCollector) 12 | } 13 | 14 | override fun getElementType(): Class { 15 | return clazz.java 16 | } 17 | } 18 | } 19 | 20 | inline fun elementValidator(crossinline validationLogic: (element: T, validatorResultCollector: ValidationResultCollector) -> Unit): ModelElementValidator{ 21 | return object:ModelElementValidator{ 22 | override fun validate(element: T, validationResultCollector: ValidationResultCollector) { 23 | validationLogic.invoke(element, validationResultCollector) 24 | } 25 | 26 | override fun getElementType(): Class { 27 | return T::class.java 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/linter/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.linter 2 | 3 | import io.zeebe.model.bpmn.instance.BaseElement 4 | import io.zeebe.model.bpmn.instance.TimeDuration 5 | import io.zeebe.model.bpmn.instance.zeebe.* 6 | import io.zeebe.model.bpmn.util.time.Interval 7 | import org.camunda.bpm.model.xml.instance.ModelElementInstance 8 | import java.time.Duration 9 | import java.time.Instant 10 | import java.time.Period 11 | 12 | 13 | /** 14 | * Returns null if no task definition info 15 | */ 16 | fun BaseElement.getZeebeTaskDefinition(): ZeebeTaskDefinition? { 17 | return this.extensionElements?.elementsQuery?.filterByType(ZeebeTaskDefinition::class.java) 18 | ?.list()?.singleOrNull() 19 | } 20 | 21 | /** 22 | * Returns null if no headers 23 | */ 24 | fun BaseElement.getZeebeTaskHeaders(): ZeebeTaskHeaders? { 25 | return this.extensionElements?.elementsQuery?.filterByType(ZeebeTaskHeaders::class.java) 26 | ?.list()?.singleOrNull() 27 | } 28 | 29 | /** 30 | * Returns nul if no subscription data 31 | */ 32 | fun BaseElement.getZeebeSubscription(): ZeebeSubscription? { 33 | return this.extensionElements?.elementsQuery?.filterByType(ZeebeSubscription::class.java) 34 | ?.list()?.singleOrNull() 35 | } 36 | 37 | /** 38 | * returns null if no IO mappings 39 | */ 40 | fun BaseElement.getZeebeIoMapping(): ZeebeIoMapping? { 41 | return this.extensionElements?.elementsQuery?.filterByType(ZeebeIoMapping::class.java) 42 | ?.list()?.singleOrNull() 43 | } 44 | 45 | /** 46 | * Returns null if no loop characteristics 47 | */ 48 | fun BaseElement.getZeebeLoopCharacteristics(): ZeebeLoopCharacteristics? { 49 | return this.extensionElements?.elementsQuery?.filterByType(ZeebeLoopCharacteristics::class.java) 50 | ?.list()?.singleOrNull() 51 | } 52 | 53 | /** 54 | * Basic helper to provide the element type name. Just saves a step. 55 | */ 56 | fun ModelElementInstance.getElementTypeName(): String{ 57 | return this.elementType.typeName 58 | } 59 | 60 | /** 61 | * Checks if model element instance is a BaseElement. BaseElements are core of BPMN elements that the Model API of Zeebe uses from Camunda. 62 | */ 63 | fun ModelElementInstance.isBaseElement(): Boolean{ 64 | return this is BaseElement 65 | } 66 | 67 | /** 68 | * Ensures that the headers have all required keys 69 | */ 70 | fun ZeebeTaskHeaders.hasRequiredKeys(keys: List): Boolean{ 71 | return this.headers.map { it.key }.containsAll(keys) 72 | } 73 | 74 | /** 75 | * Ensures that the keys list contains all of the items that are in the headers. 76 | * Should be used in together with .hasRequiredKeys() 77 | */ 78 | fun ZeebeTaskHeaders.hasOptionalAndRequiredKeys(keys: List): Boolean{ 79 | return keys.containsAll(this.headers.map { it.key }) 80 | } 81 | 82 | fun ZeebeTaskHeaders.noDuplicateKeys(restrictedKeys: List? = null): Boolean{ 83 | return if (restrictedKeys == null){ 84 | // No duplicate headers 85 | !this.headers.groupingBy { it.key }.eachCount().any { it.value > 1 } 86 | } else { 87 | // no duplicate headers only if the header is in the restricted Keys list 88 | !this.headers.filter { it.key in restrictedKeys }.groupingBy { it.key }.eachCount().any { it.value > 1 } 89 | } 90 | } 91 | 92 | /** 93 | * Gets the Timer in a Duration format (even if they provided a Period Format). 94 | */ 95 | fun TimeDuration.getDuration(startInstant: Instant = Instant.now()): Duration{ 96 | val parsedInterval = kotlin.runCatching { Interval.parse(this.textContent) } 97 | .getOrDefault(Interval(Period.ZERO, Duration.ZERO)) 98 | val due = parsedInterval.toEpochMilli(startInstant.toEpochMilli()) // @TODO Review the timezone implications of this calc 99 | return Duration.between(startInstant, Instant.ofEpochMilli(due)) 100 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/linter/LinterCfg.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.linter 2 | 3 | import io.micronaut.context.ApplicationContext 4 | import io.micronaut.context.annotation.* 5 | import io.micronaut.core.convert.ConversionContext 6 | import io.micronaut.core.convert.TypeConverter 7 | import org.camunda.bpm.model.xml.instance.ModelElementInstance 8 | import org.camunda.bpm.model.xml.validation.ModelElementValidator 9 | import java.util.* 10 | import javax.inject.Singleton 11 | import javax.validation.constraints.NotBlank 12 | import javax.validation.constraints.NotEmpty 13 | import kotlin.reflect.KClass 14 | 15 | //@TOOD Implement Regex mappings and rules 16 | 17 | /** 18 | * Linter Rule Configuration that transforms linting rules in YAML into Element Validators 19 | */ 20 | @EachProperty("orchestrator.workflow-linter.rules") 21 | @Context 22 | class LinterRule { 23 | var enabled: Boolean = true 24 | 25 | @NotBlank 26 | var description: String? = null 27 | 28 | @NotBlank @NotEmpty 29 | var elementTypes: List? = null 30 | 31 | var target: TargetRule? = null 32 | 33 | var headerRule: HeaderRule? = null 34 | var serviceTaskRule: ServiceTaskRule? = null 35 | var baseElementRule: BaseElementRule? = null 36 | 37 | 38 | @ConfigurationProperties("target") 39 | class TargetRule{ 40 | var serviceTasks: TargetsServiceTaskRule? = null 41 | var receiveTasks: TargetsReceiveTaskRule? = null 42 | 43 | @ConfigurationProperties("serviceTasks") 44 | class TargetsServiceTaskRule { 45 | var types: List = listOf() 46 | } 47 | 48 | @ConfigurationProperties("receiveTasks") 49 | class TargetsReceiveTaskRule{ 50 | var correlationKeys: List = listOf() 51 | } 52 | } 53 | 54 | @ConfigurationProperties("headerRule") 55 | class HeaderRule{ 56 | var requiredKeys: List = listOf() 57 | var optionalKeys: List = listOf() 58 | var requiredKeysRegex: Regex? = null 59 | var optionalKeysRegex: Regex? = null 60 | var allowedNonDefinedKeys: Boolean = true 61 | var allowedDuplicateKeys: Boolean = true 62 | } 63 | 64 | @ConfigurationProperties("serviceTaskRule") 65 | class ServiceTaskRule { 66 | var allowedTypes: List? = null 67 | var allowedTypesRegex: Regex? = null 68 | var allowedRetriesRegex: Regex? = null 69 | } 70 | 71 | @ConfigurationProperties("baseElementRule") 72 | class BaseElementRule { 73 | var elementNameRegex: Regex? = null 74 | } 75 | } 76 | 77 | 78 | interface ElementTypeZeebe{ 79 | val zeebeClass: KClass 80 | } 81 | 82 | enum class ElementType: ElementTypeZeebe { 83 | ServiceTask { 84 | override val zeebeClass: KClass = io.zeebe.model.bpmn.instance.ServiceTask::class 85 | } 86 | } 87 | 88 | @Singleton 89 | class ElementTypeTypeConverter: TypeConverter{ 90 | override fun convert(`object`: String, targetType: Class?, context: ConversionContext?): Optional { 91 | return Optional.of(ElementType.valueOf(`object`)) 92 | } 93 | } 94 | 95 | object LinterConfigurationParser{ 96 | fun getLintRuleBeans(applicationContext: ApplicationContext): List{ 97 | return applicationContext.getBeansOfType(LinterRule::class.java).toList() 98 | } 99 | 100 | fun lintRulesToValidators(linterRules: List): List>{ 101 | val myList: MutableList> = mutableListOf() 102 | linterRules.forEach { lr -> 103 | 104 | //Apply the rules for Each Element type that was provided 105 | // Current assumption is that rules should be aware of what elements they apply to 106 | lr.elementTypes!!.forEach { elementType -> 107 | val elementClass = elementType.zeebeClass 108 | 109 | lr.headerRule?.let { headerRule -> 110 | CommonLinterRules.processRequiredHeadersRule(elementClass, headerRule.requiredKeys, lr.target)?.let { v -> 111 | myList.add(v) 112 | } 113 | 114 | if (!headerRule.allowedNonDefinedKeys){ 115 | CommonLinterRules.processOptionalHeadersRule(elementClass, headerRule.optionalKeys, headerRule.requiredKeys, lr.target)?.let { v -> 116 | myList.add(v) 117 | } 118 | } 119 | 120 | if (!headerRule.allowedDuplicateKeys){ 121 | CommonLinterRules.processDuplicateHeaderKeysRule(elementClass, lr.target)?.let { v -> 122 | myList.add(v) 123 | } 124 | } 125 | } 126 | 127 | lr.serviceTaskRule?.let { serviceTaskRule -> 128 | //@TODO Review need to move this rule to global space for controlling global allowed types 129 | serviceTaskRule.allowedTypes?.let { types -> 130 | CommonLinterRules.processServiceTaskAllowedTypesListRule(elementClass, types, lr.target)?.let { v -> 131 | myList.add(v) 132 | } 133 | } 134 | } 135 | 136 | } 137 | 138 | } 139 | return myList 140 | } 141 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/linter/ValidationResult.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.linter 2 | 3 | import com.github.stephenott.workflowlinter.controller.Helpers 4 | import org.camunda.bpm.model.xml.validation.ValidationResultType 5 | import java.util.* 6 | 7 | data class ValidationResults( 8 | val results: Map> 9 | ) 10 | 11 | data class ValidationResult( 12 | val type: ValidationResultType, 13 | val elementId: String, 14 | val elementType: String, 15 | val message: String, 16 | val code: Int 17 | ) { 18 | constructor(validationResult: org.camunda.bpm.model.xml.validation.ValidationResult) : 19 | this( 20 | validationResult.type, 21 | Helpers.getElementWithNameAttribute(validationResult.element).getAttributeValue("id") ?: UUID.randomUUID().toString(), 22 | validationResult.element.elementType.instanceType.typeName, 23 | validationResult.message, 24 | validationResult.code) 25 | 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/linter/WorkflowLinter.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.linter 2 | 3 | import io.zeebe.model.bpmn.Bpmn 4 | import io.zeebe.model.bpmn.BpmnModelInstance 5 | import io.zeebe.model.bpmn.instance.BaseElement 6 | import org.camunda.bpm.model.xml.validation.ModelElementValidator 7 | import org.camunda.bpm.model.xml.validation.ValidationResults 8 | import java.io.File 9 | import java.io.InputStream 10 | 11 | /** 12 | * Main Linter class used to create a Linter for a specific BPMN file/inputstream 13 | */ 14 | class WorkflowLinter(inputStream: InputStream) { 15 | 16 | constructor(file: File): this(file.inputStream()) 17 | 18 | private val bpmmModelInstance: BpmnModelInstance = Bpmn.readModelFromStream(inputStream) 19 | 20 | fun lintWithValidators(validators: List>): ValidationResults{ 21 | val result = bpmmModelInstance.validate(validators) 22 | // result.results.forEach { (model, results) -> 23 | // println("Element ---> ${model.elementType.typeName} ${(model.takeIf { it is BaseElement } as BaseElement).id}") 24 | // results.forEach { validationResult -> 25 | // println(""" 26 | // Type: ${validationResult.type} 27 | // Code: ${validationResult.code} 28 | // Element Type: ${model.elementType.typeName} 29 | // Element Id: ${(model.takeIf { it is BaseElement } as BaseElement).id} 30 | // Message: ${validationResult.message} 31 | // """.trimIndent()) 32 | // } 33 | // } 34 | return result 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/linter/kts/KtsLinterRulesCfg.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.linter.kts 2 | 3 | import io.micronaut.context.annotation.ConfigurationProperties 4 | import io.micronaut.context.annotation.Context 5 | import io.micronaut.context.annotation.Requires 6 | import io.micronaut.core.convert.ConversionContext 7 | import io.micronaut.core.convert.TypeConverter 8 | import org.jetbrains.kotlin.utils.ifEmpty 9 | import java.nio.file.Files 10 | import java.nio.file.Path 11 | import java.nio.file.Paths 12 | import java.util.* 13 | import javax.inject.Singleton 14 | import kotlin.streams.toList 15 | 16 | @ConfigurationProperties("linter.kts") 17 | @Context 18 | class LinterRulesFromKtsCgf(){ 19 | var folder: Path = Paths.get("./rules") 20 | var rules: List = getRulesPathsFromRulesFolder(folder) 21 | 22 | private fun getRulesPathsFromRulesFolder(folderPath: Path): List { 23 | val extension = "kts" 24 | return if (!Files.exists(folderPath)){ 25 | println("No folder exists at $folderPath for linter rules.") 26 | listOf() 27 | } else { 28 | val rulesPaths = Files.walk(folderPath) 29 | .filter { Files.isReadable(it) } 30 | .filter { it.toFile().extension == extension }.toList() 31 | 32 | if (rulesPaths.isEmpty()){ 33 | println("No rules were found in $folderPath with .kts extensions.") 34 | } 35 | rulesPaths 36 | } 37 | } 38 | } 39 | 40 | @Singleton 41 | class PathTypeConverter: TypeConverter { 42 | override fun convert(`object`: String, targetType: Class, context: ConversionContext): Optional { 43 | return Optional.of(Paths.get(`object`)) 44 | } 45 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/stephenott/workflowlinter/linter/kts/LinterRulesFromKts.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter.linter.kts 2 | 3 | import io.micronaut.context.annotation.Context 4 | import io.micronaut.context.annotation.Requires 5 | import org.camunda.bpm.model.xml.instance.ModelElementInstance 6 | import org.camunda.bpm.model.xml.validation.ModelElementValidator 7 | import java.nio.file.Files 8 | import javax.annotation.PostConstruct 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | import javax.script.ScriptEngine 12 | import javax.script.ScriptEngineManager 13 | 14 | interface LinterRulesFromKts { 15 | fun validators(): List> 16 | } 17 | 18 | @Singleton 19 | @Requires(beans = [LinterRulesFromKtsCgf::class]) 20 | @Context 21 | class LinterServerRulesFromKts : LinterRulesFromKts { 22 | 23 | @Inject 24 | lateinit var ktsRulesList: LinterRulesFromKtsCgf 25 | 26 | private lateinit var validatorsList: List> 27 | 28 | private val scriptEngine: ScriptEngine = ScriptEngineManager().getEngineByExtension("kts") 29 | 30 | override fun validators(): List> { 31 | return validatorsList 32 | } 33 | 34 | @PostConstruct 35 | private fun processKtsFileList() { 36 | //@TODO add try catch around- ./src/main/resources/rules/rule1.kts this to capture better error handling when scriptResult does not work 37 | val paths = ktsRulesList.rules 38 | val list: MutableList> = mutableListOf() 39 | 40 | paths.forEach { path -> 41 | println("Generating linter rules from $path") 42 | val scriptReader = Files.newBufferedReader(path) 43 | val bindings = scriptEngine.createBindings() 44 | val scriptResult = scriptEngine.eval(scriptReader, bindings) 45 | scriptReader.close() 46 | 47 | try { 48 | val singleResult = scriptResult as ModelElementValidator 49 | list.add(singleResult) 50 | } catch (e: Exception){ 51 | val listResult = (scriptResult as List<*>) 52 | .filterIsInstance>() 53 | list.addAll(listResult) 54 | } catch (e: Exception){ 55 | throw IllegalStateException("script $path did not return a model element validator or list of model element validators.") 56 | } 57 | } 58 | validatorsList = list 59 | } 60 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/javax.script.ScriptEngineFactory: -------------------------------------------------------------------------------- 1 | org.jetbrains.kotlin.script.jsr223.KotlinJsr223JvmLocalScriptEngineFactory -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | micronaut: 2 | application: 3 | name: workflow-linter 4 | 5 | #linter: 6 | # kts: 7 | # rules: 8 | # - ./src/main/resources/rules/rule1.kts 9 | # - ./src/main/resources/rules/ruleTimerMin.kts -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | 7 | 8 | %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/resources/rules/rule1.kts: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import com.github.stephenott.workflowlinter.linter.elementValidator 4 | import io.zeebe.model.bpmn.instance.BaseElement 5 | 6 | elementValidator { e, v -> 7 | v.addWarning(0, "hahah") 8 | } -------------------------------------------------------------------------------- /src/main/resources/rules/ruleTimerMin.kts: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import com.github.stephenott.workflowlinter.linter.elementValidator 4 | import com.github.stephenott.workflowlinter.linter.getDuration 5 | import io.zeebe.model.bpmn.instance.TimeDuration 6 | 7 | elementValidator { e, v -> 8 | if (e.getDuration().seconds in 0..60){ 9 | v.addError(60, "Timers Durations must be greater than 60 seconds.") 10 | } 11 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/github/stephenott/workflowlinter/LinterTest1.kt: -------------------------------------------------------------------------------- 1 | package com.github.stephenott.workflowlinter 2 | 3 | 4 | //@MicronautTest 5 | //class LinterTest1( 6 | // private var server: EmbeddedServer 7 | //): StringSpec({ 8 | // "Test Linter"{ 9 | // 10 | // val file = File("src/test/resources/test1.bpmn") 11 | // 12 | //// val rules: List = 13 | //// LinterConfigurationParser.getLintRuleBeans(server.applicationContext) 14 | //// 15 | //// val validators: List> = 16 | //// LinterConfigurationParser.lintRulesToValidators(rules) 17 | //// 18 | //// 19 | //// WorkflowLinter(file).lintWithValidators(validators) 20 | // 21 | // val cleanerValidators = listOf( 22 | // elementValidator { element, validatorResultCollector -> 23 | // validatorResultCollector.addError(5000, "cleaner code") 24 | // } 25 | // ) 26 | // 27 | // val cleanModel = Cleaner(cleanerValidators).cleanModel(file) 28 | // println(Bpmn.convertToString(cleanModel)) 29 | // 30 | // } 31 | //}) -------------------------------------------------------------------------------- /src/test/kotlin/io/kotlintest/provided/ProjectConfig.kt: -------------------------------------------------------------------------------- 1 | package io.kotlintest.provided 2 | 3 | import io.kotlintest.AbstractProjectConfig 4 | import io.micronaut.test.extensions.kotlintest.MicornautKotlinTestExtension 5 | 6 | object ProjectConfig : AbstractProjectConfig() { 7 | override fun listeners() = listOf(MicornautKotlinTestExtension) 8 | override fun extensions() = listOf(MicornautKotlinTestExtension) 9 | } 10 | -------------------------------------------------------------------------------- /src/test/resources/test1.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Task_1pjpqz0 13 | Task_10ugd34 14 | Task_00znhpl 15 | Task_05r7ocx 16 | IntermediateThrowEvent_0ue7kjs 17 | IntermediateThrowEvent_0pftlc9 18 | IntermediateThrowEvent_0rqamn8 19 | IntermediateThrowEvent_1jlnvjm 20 | IntermediateThrowEvent_1c9t58w 21 | 22 | 23 | 24 | SequenceFlow_1hnbx9h 25 | 26 | 27 | SequenceFlow_1hnbx9h 28 | SequenceFlow_1uts8z1 29 | 30 | 31 | SequenceFlow_0bjdega 32 | 33 | 34 | SequenceFlow_1uts8z1 35 | SequenceFlow_135m3sa 36 | SequenceFlow_0bjdega 37 | SequenceFlow_1oknyaf 38 | 39 | 40 | SequenceFlow_1oknyaf 41 | 42 | 43 | SequenceFlow_1edsqdq 44 | 45 | 46 | 47 | SequenceFlow_1edsqdq 48 | SequenceFlow_1fetaup 49 | 50 | 51 | 52 | SequenceFlow_1fetaup 53 | 54 | 55 | 56 | 57 | SequenceFlow_15yu6zp 58 | 59 | 60 | 61 | SequenceFlow_15yu6zp 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | SequenceFlow_135m3sa 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | www 96 | 97 | 98 | happy 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | --------------------------------------------------------------------------------