camunda-workflow 0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CONTRIBUTING.md +20 -0
  3. data/LICENSE.md +31 -0
  4. data/README.md +209 -0
  5. data/lib/camunda.rb +54 -0
  6. data/lib/camunda/bpmn_xml.rb +52 -0
  7. data/lib/camunda/deployment.rb +29 -0
  8. data/lib/camunda/external_task.rb +83 -0
  9. data/lib/camunda/external_task_job.rb +35 -0
  10. data/lib/camunda/incident.rb +3 -0
  11. data/lib/camunda/matchers.rb +20 -0
  12. data/lib/camunda/model.rb +26 -0
  13. data/lib/camunda/poller.rb +8 -0
  14. data/lib/camunda/process_definition.rb +29 -0
  15. data/lib/camunda/process_instance.rb +21 -0
  16. data/lib/camunda/signal.rb +9 -0
  17. data/lib/camunda/task.rb +28 -0
  18. data/lib/camunda/variable_serialization.rb +48 -0
  19. data/lib/camunda/workflow.rb +39 -0
  20. data/lib/generators/camunda/bpmn_classes/USAGE +10 -0
  21. data/lib/generators/camunda/bpmn_classes/bpmn_classes_generator.rb +67 -0
  22. data/lib/generators/camunda/bpmn_classes/templates/bpmn_class.rb.template +10 -0
  23. data/lib/generators/camunda/bpmn_classes/templates/bpmn_module.rb.template +5 -0
  24. data/lib/generators/camunda/install/USAGE +10 -0
  25. data/lib/generators/camunda/install/install_generator.rb +11 -0
  26. data/lib/generators/camunda/install/templates/camunda_job.rb +7 -0
  27. data/lib/generators/camunda/spring_boot/USAGE +12 -0
  28. data/lib/generators/camunda/spring_boot/spring_boot_generator.rb +69 -0
  29. data/lib/generators/camunda/spring_boot/templates/Camunda.java +28 -0
  30. data/lib/generators/camunda/spring_boot/templates/ProcessScenarioTest.java +85 -0
  31. data/lib/generators/camunda/spring_boot/templates/application.properties +48 -0
  32. data/lib/generators/camunda/spring_boot/templates/camunda.cfg.xml +20 -0
  33. data/lib/generators/camunda/spring_boot/templates/pom.xml +173 -0
  34. data/lib/generators/camunda/spring_boot/templates/sample.bpmn +94 -0
  35. metadata +230 -0
@@ -0,0 +1,35 @@
1
+ module Camunda::ExternalTaskJob
2
+ def perform(id, input_variables)
3
+ output_variables = bpmn_perform(input_variables)
4
+ output_variables = {} if output_variables.nil?
5
+ raise ArgumentError, "Expected a hash, got #{output_variables}" unless output_variables.is_a?(Hash)
6
+
7
+ report_completion id, output_variables
8
+ rescue Camunda::BpmnError => e
9
+ report_bpmn_error id, e
10
+ rescue StandardError => e
11
+ report_failure id, e, input_variables
12
+ end
13
+
14
+ def report_completion(id, variables)
15
+ # Submit to Camunda using
16
+ # POST /external-task/{id}/complete
17
+ Camunda::ExternalTask.new(id: id).complete(variables)
18
+ end
19
+
20
+ def report_failure(id, exception, input_variables)
21
+ # Submit error state to Camunda using
22
+ # POST /external-task/{id}/failure
23
+ Camunda::ExternalTask.new(id: id).failure(exception, input_variables)
24
+ end
25
+
26
+ def report_bpmn_error(id, exception)
27
+ # Submit bpmn error state to Camunda using
28
+ # POST /external-task/{id}/bpmnError
29
+ Camunda::ExternalTask.new(id: id).bpmn_error(exception)
30
+ end
31
+
32
+ def bpmn_perform(_variables)
33
+ raise StandardError, "Please define this method which takes a hash of variables and returns a hash of variables"
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ class Camunda::Incident < Camunda::Model
2
+ collection_path 'incident'
3
+ end
@@ -0,0 +1,20 @@
1
+ RSpec::Matchers.define :have_topics do |topic_names|
2
+ match { |bpmn_xml| topic_names.sort == bpmn_xml.topics.sort }
3
+ failure_message { |bpmn_xml| "Expected #{topic_names}. Found #{bpmn_xml.topics.sort}" }
4
+ end
5
+
6
+ RSpec::Matchers.define :have_module do |module_name_expected|
7
+ match { |bpmn_xml| module_name_expected == bpmn_xml.module_name }
8
+ failure_message { |bpmn_xml| "ID of the BPMN process is #{bpmn_xml.module_name}. Expected #{module_name_expected}" }
9
+ end
10
+
11
+ RSpec::Matchers.define :have_defined_classes do
12
+ missing_classes = []
13
+ match do |bpmn_xml|
14
+ missing_classes = bpmn_xml.modularized_class_names.reject(&:safe_constantize)
15
+ missing_classes.empty?
16
+ end
17
+ failure_message do |_bpmn_xml|
18
+ "#{missing_classes} are not defined. They are the expected classes in your Rails app to implement the workers."
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ require 'her/model'
2
+ class Camunda::Model
3
+ include Her::Model
4
+
5
+ api = lambda do
6
+ Her::API.new(url: File.join(Camunda::Workflow.configuration.engine_url)) do |c|
7
+ c.path_prefix = Camunda::Workflow.configuration.engine_route_prefix
8
+ # Request
9
+ c.use Faraday::Request::Multipart
10
+ c.use FaradayMiddleware::EncodeJson
11
+ # Response
12
+ c.use Faraday::Response::Logger, ActiveSupport::Logger.new(STDOUT), bodies: true if Rails.env.development?
13
+ c.use Her::Middleware::FirstLevelParseJSON
14
+
15
+ c.use Her::Middleware::SnakeCase
16
+ # Adapter
17
+ c.adapter :net_http
18
+ end
19
+ end
20
+
21
+ use_api api
22
+
23
+ def self.worker_id
24
+ Camunda::Workflow.configuration.worker_id
25
+ end
26
+ end
@@ -0,0 +1,8 @@
1
+ class Camunda::Poller
2
+ def self.fetch_and_execute(topics, lock_duration: nil, long_polling_duration: nil)
3
+ loop do
4
+ Camunda::ExternalTask
5
+ .fetch_and_lock(topics, lock_duration: lock_duration, long_polling_duration: long_polling_duration).each(&:queue_task)
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,29 @@
1
+ class Camunda::ProcessDefinition < Camunda::Model
2
+ include Camunda::VariableSerialization
3
+ collection_path 'process-definition'
4
+
5
+ def self.start_by_key(key, hash={})
6
+ hash[:variables] = serialize_variables(hash[:variables]) if hash[:variables]
7
+ tenant_id = hash.delete(:tenant_id)
8
+ tenant_id ||= Camunda::Workflow.configuration.tenant_id
9
+
10
+ response = post_raw start_path_for_key(key, tenant_id), hash
11
+ raise Camunda::ProcessEngineException, response[:parsed_data][:data][:message] unless response[:response].status == 200
12
+
13
+ Camunda::ProcessInstance.new response[:parsed_data][:data]
14
+ end
15
+
16
+ def start(hash={})
17
+ hash[:variables] = serialize_variables(hash[:variables]) if hash[:variables]
18
+ response = self.class.post_raw "process-definition/#{id}/start", hash
19
+ raise Camunda::ProcessEngineException, response[:parsed_data][:data][:message] unless response[:response].status == 200
20
+
21
+ Camunda::ProcessInstance.new response[:parsed_data][:data]
22
+ end
23
+
24
+ def self.start_path_for_key(key, tenant_id)
25
+ path = "process-definition/key/#{key}"
26
+ path << "/tenant-id/#{tenant_id}" if tenant_id
27
+ "#{path}/start"
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ class Camunda::ProcessInstance < Camunda::Model
2
+ collection_path 'process-instance'
3
+
4
+ def variables
5
+ response = self.class.get_raw "process-instance/#{id}/variables"
6
+ deserialize_variables response[:parsed_data][:data]
7
+ end
8
+
9
+ private
10
+
11
+ def deserialize_variables(hash)
12
+ hash.transform_values do |value_hash|
13
+ case value_hash[:type]
14
+ when "String", "Double", "Integer", "Boolean"
15
+ value_hash[:value]
16
+ when "Json"
17
+ value_hash[:value][:node_type]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ class Camunda::Signal < Camunda::Model
2
+ include Camunda::VariableSerialization
3
+ collection_path 'signal'
4
+
5
+ def self.create(hash={})
6
+ hash[:variables] = serialize_variables(hash[:variables]) if hash[:variables]
7
+ post_raw collection_path, hash
8
+ end
9
+ end
@@ -0,0 +1,28 @@
1
+ class Camunda::Task < Camunda::Model
2
+ include Camunda::VariableSerialization
3
+ collection_path 'task'
4
+
5
+ def self.find_by_business_key_and_task_definition_key!(instance_business_key, task_key)
6
+ find_by(instanceBusinessKey: instance_business_key, taskDefinitionKey: task_key).tap do |ct|
7
+ unless ct
8
+ raise MissingTask, "Could not find Camunda Task with processInstanceBusinessKey: #{instance_business_key} " \
9
+ "and taskDefinitionKey #{task_key}"
10
+ end
11
+ end
12
+ end
13
+
14
+ def self.mark_task_completed!(instance_business_key, task_key, variables={})
15
+ ct = find_by_business_key_and_task_definition_key!(instance_business_key, task_key)
16
+ ct.complete! variables
17
+ end
18
+
19
+ def complete!(vars)
20
+ self.class.post_raw("#{self.class.collection_path}/#{id}/complete", variables: serialize_variables(vars))[:response]
21
+ .tap do |response|
22
+ raise MissingTask unless response.success?
23
+ end
24
+ end
25
+
26
+ class MissingTask < StandardError
27
+ end
28
+ end
@@ -0,0 +1,48 @@
1
+ require 'active_support/concern'
2
+ module Camunda
3
+ module VariableSerialization
4
+ extend ActiveSupport::Concern
5
+
6
+ def serialize_variables(variables)
7
+ self.class.serialize_variables(variables)
8
+ end
9
+
10
+ class_methods do
11
+ # rubocop:disable Metrics/MethodLength
12
+ def serialize_variables(variables)
13
+ hash = variables.transform_values do |value|
14
+ case value
15
+ when String
16
+ { value: value, type: 'String' }
17
+ when Array, Hash
18
+ { value: transform_json(value).to_json, type: 'Json' }
19
+ when TrueClass, FalseClass
20
+ { value: value, type: 'Boolean' }
21
+ when Integer
22
+ { value: value, type: 'Integer' }
23
+ when Float
24
+ { value: value, type: 'Double' }
25
+ else
26
+ raise ArgumentError, "Not supporting complex types yet"
27
+ end
28
+ end
29
+ camelcase_keys(hash)
30
+ end
31
+ # rubocop:enable Metrics/MethodLength
32
+
33
+ def transform_json(json)
34
+ if json.is_a?(Array)
35
+ json.map { |element| transform_json(element) }
36
+ elsif json.is_a?(Hash)
37
+ camelcase_keys(json)
38
+ else
39
+ json
40
+ end
41
+ end
42
+
43
+ def camelcase_keys(hash)
44
+ hash.deep_transform_keys { |key| key.to_s.camelcase(:lower) }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ module Camunda
2
+ module Workflow
3
+ def self.configure
4
+ yield(configuration)
5
+ end
6
+
7
+ def self.configuration
8
+ @configuration ||= Configuration.new
9
+ end
10
+
11
+ class Configuration
12
+ attr_accessor :engine_url
13
+ attr_accessor :engine_route_prefix
14
+ attr_accessor :worker_id
15
+ attr_accessor :lock_duration
16
+ attr_accessor :max_polling_tasks
17
+ attr_accessor :long_polling_duration
18
+ attr_accessor :tenant_id
19
+
20
+ def initialize
21
+ @engine_url = 'http://localhost:8080'
22
+ @engine_route_prefix = 'rest-engine'
23
+ @worker_id = '0'
24
+ @lock_duration = 14.days
25
+ @max_polling_tasks = 2
26
+ @long_polling_duration = 30.seconds
27
+ @tenant_id = if defined?(Rails)
28
+ Rails.env.test? ? 'test-environment' : nil
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ %w[../camunda.rb variable_serialization.rb model.rb task.rb external_task.rb external_task_job.rb poller.rb process_definition.rb
36
+ process_instance.rb deployment.rb signal.rb bpmn_xml.rb incident.rb]
37
+ .each do |file|
38
+ require File.join(__dir__, file)
39
+ end
@@ -0,0 +1,10 @@
1
+ Description:
2
+ Generates BPMN classes for a BPMN XML in the current Rails app. They will be under app/bpmn. It will first validate the
3
+ bpmn xml to make sure the class names are all populated as expected.
4
+
5
+ Example:
6
+ rails generate camunda:bpmn_classes bpmn_file
7
+
8
+ This will create:
9
+ app/bpmn/#{process_key}/class1
10
+ app/bpmn/#{process_key}/class2
@@ -0,0 +1,67 @@
1
+ module Camunda
2
+ module Generators
3
+ class BpmnClassesGenerator < Rails::Generators::Base
4
+ source_root File.expand_path('templates', __dir__)
5
+ argument :bpmn_file, type: :string
6
+ class_option :model_path, type: :string, default: 'app/bpmn'
7
+
8
+ def validate_module_name
9
+ puts "The id of the BPMN process is: #{colored_module_name}. That will be your module name."
10
+ validate_constant_name(module_name)
11
+ end
12
+
13
+ def validate_class_names
14
+ bpmn_xml.modularized_class_names.each do |class_name|
15
+ validate_constant_name(class_name.demodulize, module_name)
16
+ end
17
+ puts set_color("External tasks with the same topic name as the BPMN id will be created.", :bold)
18
+ colorized_class_names = bpmn_xml.modularized_class_names.map! { |class_name| set_color class_name, :red }
19
+ puts colorized_class_names.join("\n")
20
+ end
21
+
22
+ def create_module
23
+ template 'bpmn_module.rb.template', File.join(model_path, "#{module_name.underscore}.rb")
24
+ end
25
+
26
+ def create_classes
27
+ bpmn_xml.class_names_with_same_bpmn_id_as_topic.each do |class_name|
28
+ template 'bpmn_class.rb.template',
29
+ File.join(model_path, module_name.underscore, "#{class_name.underscore}.rb"), class_name: class_name
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def validate_constant_name(name, module_name=nil)
36
+ top_level = module_name.nil? ? Module.new : module_name.constantize
37
+ colorized_name = set_color name, :red
38
+ begin
39
+ Object.const_set(name, top_level)
40
+ rescue NameError
41
+ puts "Cannot create a class/module with name #{colorized_name}. Not a valid name."
42
+ puts "You must set the ID in Camunda to be the name of a Ruby style constant"
43
+ end
44
+ return unless name.include?("_")
45
+
46
+ puts "Class name #{colorized_name} should not have an underscore _."
47
+ puts "Underscores are valid Ruby constant names, but likely you have not changed the name from the default."
48
+ end
49
+
50
+ def model_path
51
+ options['model_path']
52
+ end
53
+
54
+ def module_name
55
+ @module_name ||= bpmn_xml.module_name
56
+ end
57
+
58
+ def bpmn_xml
59
+ @bpmn_xml ||= BpmnXML.new(File.open(bpmn_file))
60
+ end
61
+
62
+ def colored_module_name
63
+ @colored_module_name ||= set_color module_name, :red
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,10 @@
1
+ module <%= module_name %>
2
+ class <%= config[:class_name] %> < CamundaJob
3
+ include <%= module_name %>
4
+
5
+ def bpmn_perform(_variables)
6
+ raise StandardError,
7
+ "Please define this method to perform your task which takes a hash of variables and returns a hash of variables"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ module <%= module_name %>
2
+ # You can use this module for shared behavior. You may also use ActiveSupport::Concern if you wish.
3
+ # That means it not only acts as a namespace, but can be be included in a class to let this module behave as a mixin.
4
+ # extend ActiveSupport::Concern
5
+ end
@@ -0,0 +1,10 @@
1
+ Description:
2
+ One time install of ActiveJob integrations for Camunda jobs
3
+
4
+ Example:
5
+ rails generate camunda:install
6
+
7
+ This will create:
8
+ app/jobs/camunda_job.rb
9
+
10
+ which includes Camunda::ExternalTaskJob. It can be further customized
@@ -0,0 +1,11 @@
1
+ module Camunda
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path('templates', __dir__)
5
+
6
+ def copy_camunda_application_job
7
+ copy_file 'camunda_job.rb', 'app/jobs/camunda_job.rb'
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ class CamundaJob < ApplicationJob
2
+ # If using Sidekiq change to include Sidekiq::Worker instead of inheriting from ApplicationJob
3
+ include Camunda::ExternalTaskJob
4
+ # queue_as :camunda
5
+
6
+ # Customize if needed for your Camunda background task instances
7
+ end
@@ -0,0 +1,12 @@
1
+ Description:
2
+ One time install of Java Camunda application to do unit testing of your BPMN process
3
+
4
+ Example:
5
+ rails generate camunda:unit_tests
6
+
7
+ This will create a Java app in:
8
+ bpmn/java_app
9
+
10
+ with a sample BPMN file and Unit Test.
11
+
12
+ The suggested location for your BPMN file is in `bpmn/diagrams`. `bpmn/java_app/src/main/resources` is a symlink to that.
@@ -0,0 +1,69 @@
1
+ module Camunda
2
+ module Generators
3
+ class SpringBootGenerator < Rails::Generators::Base
4
+ source_root File.expand_path('templates', __dir__)
5
+ class_option :app_path, type: :string, default: 'bpmn/java_app'
6
+ class_option :diagram_path, type: :string, default: 'bpmn/diagrams'
7
+
8
+ def copy_java_app_files
9
+ copy_file 'pom.xml', File.join(bpmn_app_path, 'pom.xml')
10
+ copy_file 'camunda.cfg.xml', File.join(bpmn_app_path, 'src/test/resources/camunda.cfg.xml')
11
+ copy_file 'application.properties', File.join(bpmn_app_path, 'src/main/resources/application.properties')
12
+ copy_file 'ProcessScenarioTest.java', File.join(bpmn_app_path, 'src/test/java/unittest/ProcessScenarioTest.java')
13
+ copy_file 'Camunda.java', File.join(bpmn_app_path, 'src/main/java/camunda/Camunda.java')
14
+ end
15
+
16
+ def link_resources_folder
17
+ copy_file 'sample.bpmn', File.join(diagram_path, 'sample.bpmn'), ''
18
+ end
19
+
20
+ def add_to_ignores
21
+ %w[.gitignore .cfignore].each do |file|
22
+ append_to_file file do
23
+ "\n# BPMN Java app\n" +
24
+ File.join(bpmn_app_path, 'target') +
25
+ "\n"
26
+ end
27
+ end
28
+ end
29
+
30
+ def output_error_instructions
31
+ puts <<~DOC
32
+ If you get an error when starting your Rails app
33
+
34
+ ** ERROR: directory is already being watched! **
35
+
36
+ Directory: bpmn/java_app/src/main/resources
37
+ is already being watched through: bpmn/diagrams
38
+
39
+ MORE INFO: https://github.com/guard/listen/wiki/Duplicate-directory-errors
40
+
41
+ It is because ActionMailer preview causes test/mailers/previews to get added to the Rails EventedFileChecker
42
+ by default. RSpec is supposed to override it, but it is not overridden properly for EventedFileChecker and/or
43
+ you don't have spec/mailers/preview existing. If that directory does not exist it goes to the first common
44
+ directory that exists which is your Rails root folder.
45
+
46
+ So EventedFileChecker is listening to your entire Rails folder. Not a big problem, but it causes a problem
47
+ for our created symlink.
48
+
49
+ So add:
50
+
51
+ config.action_mailer.show_previews = false
52
+
53
+ to your development.rb file to solve Listen errors about a symlink. Unless you are using ActionMailer
54
+ previews in which case you should have the directory created already.
55
+ DOC
56
+ end
57
+
58
+ private
59
+
60
+ def bpmn_app_path
61
+ options['app_path']
62
+ end
63
+
64
+ def diagram_path
65
+ options['diagram_path']
66
+ end
67
+ end
68
+ end
69
+ end