camunda-workflow 0.1

Sign up to get free protection for your applications and to get access to all the features.
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