ruby-sfn-local 0.1.3

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1c56a390adcd8cefff1fa95e14db7b966e40058f81fe7d775edf31256d7eaf76
4
+ data.tar.gz: 7da9325fb01e571bf5769e6d890547c91f684742cd872d43decbd0b6bd798f1c
5
+ SHA512:
6
+ metadata.gz: e88364b74f3ed18ba23d4aab7b1e7df243a56893693af3c30531ef04c9cbc0e6f91657eb59f291eeef6e131ed3b6e55a3720cf7ce9dedb68096cce2e26c1ec1b
7
+ data.tar.gz: 3091356de9b879a01978babcd45ca8939f4d3b8f4e28a84e9e1c5f7dc1df4d2c8662867cdebaf677553adbfeee8401824d5ee0ddb61ba00b99455aabbd0dbade
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Sfn
6
+ class AwsCli
7
+ class ExecutionError < StandardError; end
8
+
9
+ def self.run(mod, command, params, key = nil, debug_info = '')
10
+ cmd = "#{Sfn.configuration.aws_command || 'aws'} #{mod} #{command} \
11
+ --endpoint #{Sfn.configuration.aws_endpoint} \
12
+ #{params.map do |k, v|
13
+ "--#{k}=#{v}"
14
+ end.join(' ')} \
15
+ --no-cli-pager"
16
+
17
+ stdout, stderr, _status = Open3.capture3(cmd)
18
+ raise raise ExecutionError, "#{stderr.strip}\n#{debug_info}" unless stderr.strip.empty?
19
+
20
+ stdout = `#{cmd}`
21
+ unless key.nil?
22
+ data = JSON.parse(stdout)
23
+ return data[key].strip if data.key?(key)
24
+ end
25
+
26
+ stdout
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module Sfn
6
+ class Collection
7
+ include Singleton
8
+
9
+ attr_reader :all
10
+
11
+ def initialize
12
+ response = AwsCli.run('stepfunctions', 'list-state-machines', {})
13
+ parsed_response = JSON.parse(response)
14
+ @all = parsed_response['stateMachines'] || []
15
+ end
16
+
17
+ def add(state_machine)
18
+ @all.push(state_machine.slice('stateMachineArn', 'name'))
19
+ end
20
+
21
+ def delete_by_arn(state_machine_arn)
22
+ @all.delete_if { |sf| sf['stateMachineArn'] == state_machine_arn }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ module Sfn
5
+ def self.configuration
6
+ @configuration ||= OpenStruct.new
7
+ end
8
+
9
+ def self.configure
10
+ yield(configuration)
11
+ end
12
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sfn
4
+ class Execution
5
+ attr_accessor :uuid, :state_machine, :test_case, :arn, :output, :profile
6
+
7
+ def self.call(state_machine, test_case, mock_data, input)
8
+ new(state_machine, test_case).exec(mock_data, input)
9
+ end
10
+
11
+ def initialize(state_machine, test_case)
12
+ self.uuid = SecureRandom.uuid
13
+ self.state_machine = state_machine
14
+ self.test_case = test_case.camelize
15
+ end
16
+
17
+ def exec(mock_data, input)
18
+ MockData.write_context(state_machine.name, test_case, mock_data)
19
+ self.arn = AwsCli.run('stepfunctions', 'start-execution',
20
+ { name: uuid,
21
+ 'state-machine': "#{state_machine.arn}##{test_case}",
22
+ input: "'#{input.to_json}'" },
23
+ 'executionArn')
24
+ self.output, self.profile = ExecutionLog.parse(arn)
25
+ self
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sfn
4
+ class ExecutionError < RuntimeError
5
+ attr_accessor :code, :events
6
+
7
+ def initialize(msg, code, events)
8
+ self.code = code
9
+ self.events = JSON.parse(events)
10
+ super(msg)
11
+ end
12
+ end
13
+
14
+ class ExecutionLog
15
+ attr_accessor :event
16
+
17
+ EVENTS = %w[stateEnteredEventDetails stateExitedEventDetails executionSucceededEventDetails
18
+ executionFailedEventDetails].freeze
19
+ def self.parse(execution_arn)
20
+ profile = {}
21
+ output = nil
22
+ error = nil
23
+ events_json = AwsCli.run('stepfunctions', 'get-execution-history',
24
+ { 'execution-arn': execution_arn.to_s, query: "'events[?#{EVENTS.join(' || ')}]'" })
25
+
26
+ JSON.parse(events_json).each do |event|
27
+ parsed_event = new(event)
28
+
29
+ output ||= parsed_event.output
30
+ error ||= parsed_event.error(events_json)
31
+ state_name = parsed_event.state_name
32
+
33
+ next if state_name.nil?
34
+
35
+ profile[state_name] ||= { input: [], output: [] }
36
+ profile[state_name][:input] << parsed_event.profile[:input] unless parsed_event.profile[:input].nil?
37
+ profile[state_name][:output] << parsed_event.profile[:output] unless parsed_event.profile[:output].nil?
38
+ end
39
+ [output, profile]
40
+ end
41
+
42
+ def initialize(event)
43
+ self.event = event
44
+ end
45
+
46
+ def state_name
47
+ event.dig('stateEnteredEventDetails', 'name') || event.dig('stateExitedEventDetails', 'name')
48
+ end
49
+
50
+ def output
51
+ try_parse(event.dig('executionSucceededEventDetails', 'output'))
52
+ end
53
+
54
+ def error(events_json = '{}')
55
+ return if event['executionFailedEventDetails'].nil?
56
+
57
+ raise ExecutionError.new(event['executionFailedEventDetails']['cause'],
58
+ event['executionFailedEventDetails']['error'],
59
+ events_json)
60
+ end
61
+
62
+ def profile
63
+ {
64
+ input: try_parse(event.dig('stateEnteredEventDetails', 'input')),
65
+ output: try_parse(event.dig('stateExitedEventDetails', 'output'))
66
+ }.compact
67
+ end
68
+
69
+ private
70
+
71
+ def try_parse(json_string)
72
+ JSON.parse(json_string)
73
+ rescue StandardError
74
+ nil
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'openssl'
5
+
6
+ module Sfn
7
+ module MockData
8
+ def self.write_context(state_machine_name, context, mock_data = {})
9
+ data = {
10
+ 'StateMachines' => {
11
+ state_machine_name.to_s => {
12
+ 'TestCases' => {
13
+ context.to_s => { 'foo' => 'bar' }
14
+ }
15
+ }
16
+ },
17
+ 'MockedResponses' => { 'bar' => {} }
18
+ }
19
+
20
+ mock_data.each do |step, response|
21
+ uuid = OpenSSL::Digest::SHA512.digest({ step: step }.merge(response).to_json).camelize
22
+ data['StateMachines'][state_machine_name.to_s]['TestCases'][context.to_s][step.to_s] = uuid
23
+ data['MockedResponses'][uuid] = response
24
+ end
25
+ File.write(Sfn.configuration.mock_file_path, JSON.pretty_generate(data))
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sfn
4
+ module MockMacros
5
+ module ApiGateway
6
+ def self.response(data)
7
+ data = [data] if data.is_a?(Hash)
8
+ data.map! do |val|
9
+ if val.key?(:error)
10
+ { Throw: { Error: val[:error], Cause: val[:cause] } }
11
+ else
12
+ val[:response] ||= val[:payload]
13
+ val[:response] ||= val[:output]
14
+ { Return: { Headers: val[:headers], ResponseBody: val[:response], StatusCode: val[:status],
15
+ StatusText: 'OK' } }
16
+ end
17
+ end
18
+ out = {}
19
+ data.each_with_index do |val, idx|
20
+ out[idx.to_s] = val
21
+ end
22
+ out
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sfn
4
+ module MockMacros
5
+ module Lambda
6
+ def self.response(data)
7
+ data = [data] if data.is_a?(Hash)
8
+ data.map! do |val|
9
+ if val.key?(:error)
10
+ { Throw: { Error: val[:error], Cause: val[:cause] } }
11
+ else
12
+ val[:payload] ||= val[:response]
13
+ val[:payload] ||= val[:output]
14
+ { Return: { Payload: val[:payload], StatusCode: 200 } }
15
+ end
16
+ end
17
+ out = {}
18
+ data.each_with_index do |val, idx|
19
+ out[idx.to_s] = val
20
+ end
21
+ out
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sfn
4
+ module MockMacros
5
+ module Sns
6
+ def self.response(data)
7
+ data = [data] if data.is_a?(Hash)
8
+ data.map! do |val|
9
+ if val.key?(:error)
10
+ { Throw: { Error: val[:error], Cause: val[:cause] } }
11
+ else
12
+ val[:uuid] ||= SecureRandom.uuid
13
+ val[:sequence] ||= 10_000_000_000_000_003_000
14
+ { Return: { MessageId: val[:uuid], SequenceNumber: val[:sequence] } }
15
+ end
16
+ end
17
+ out = {}
18
+ data.each_with_index do |val, idx|
19
+ out[idx.to_s] = val
20
+ end
21
+ out
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sfn
4
+ module MockMacros
5
+ module StepFunction
6
+ def self.response(data)
7
+ data = [data] if data.is_a?(Hash)
8
+ data.map! do |val|
9
+ if val.key?(:error)
10
+ { Throw: { Error: val[:error], Cause: val[:cause] } }
11
+ else
12
+ val[:output] ||= val[:payload]
13
+ val[:output] ||= val[:response]
14
+ { Return: { Output: val[:output].to_json, Status: 'SUCCEDED' } }
15
+ end
16
+ end
17
+ out = {}
18
+ data.each_with_index do |val, idx|
19
+ out[idx.to_s] = val
20
+ end
21
+ out
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sfn/mock_macros/lambda'
4
+ require 'sfn/mock_macros/step_function'
5
+ require 'sfn/mock_macros/api_gateway'
6
+ require 'sfn/mock_macros/sns'
7
+
8
+ module Sfn
9
+ module MockMacros
10
+ include Lambda
11
+ include StepFunction
12
+ include ApiGateway
13
+ include Sns
14
+
15
+ def self.gateway_response(data)
16
+ ApiGateway.response(data)
17
+ end
18
+
19
+ def self.lambda_response(data)
20
+ Lambda.response(data)
21
+ end
22
+
23
+ def self.sns_response(data)
24
+ Sns.response(data)
25
+ end
26
+
27
+ def self.step_function_response(data)
28
+ StepFunction.response(data)
29
+ end
30
+
31
+ def self.gateway_payload(data)
32
+ warn '[DEPRECATION] `gateway_payload` is deprecated. Please use `gateway_response` instead.'
33
+ ApiGateway.response(data)
34
+ end
35
+
36
+ def self.lambda_payload(data)
37
+ warn '[DEPRECATION] `lambda_payload` is deprecated. Please use `lambda_response` instead.'
38
+ Lambda.response(data)
39
+ end
40
+
41
+ def self.sns_payload(data)
42
+ warn '[DEPRECATION] `sns_payload` is deprecated. Please use `sns_response` instead.'
43
+ Sns.response(data)
44
+ end
45
+
46
+ def self.step_function_payload(data)
47
+ warn '[DEPRECATION] `step_function_payload` is deprecated. Please use `step_function_response` instead.'
48
+ StepFunction.response(data)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'openssl'
5
+
6
+ module Sfn
7
+ class DefinitionError < RuntimeError; end
8
+
9
+ class StateMachine
10
+ ROLE = 'arn:aws:iam::123456789012:role/DummyRole'
11
+
12
+ attr_accessor :name, :definition, :arn, :executions, :execution_arn
13
+
14
+ def self.all
15
+ Collection.instance.all.map { |sf| new(sf['name'], sf['stateMachineArn']) }
16
+ end
17
+
18
+ def self.destroy_all
19
+ all.each(&:destroy)
20
+ end
21
+
22
+ def self.find_by_name(name)
23
+ all.find { |sf| sf.name == name }
24
+ end
25
+
26
+ def self.find_by_arn(arn)
27
+ all.find { |sf| sf.arn == arn }
28
+ end
29
+
30
+ def initialize(name, arn = nil)
31
+ self.name = name
32
+ self.arn = arn || self.class.find_by_name(name)&.arn || create_state_machine
33
+ self.executions = {}
34
+ end
35
+
36
+ def destroy
37
+ AwsCli.run('stepfunctions', 'delete-state-machine',
38
+ { 'state-machine-arn': arn })
39
+ Collection.instance.delete_by_arn(arn)
40
+ end
41
+
42
+ def run(mock_data = {}, input = {}, test_name = nil)
43
+ test_name ||= OpenSSL::Digest::SHA512.digest(mock_data.merge(input).to_json)
44
+ executions[test_name] ||= Execution.call(self, test_name, mock_data, input)
45
+ executions[test_name]
46
+ end
47
+
48
+ def to_hash
49
+ { 'stateMachineArn' => arn, 'name' => name }
50
+ end
51
+
52
+ private
53
+
54
+ def create_state_machine
55
+ self.arn = AwsCli.run('stepfunctions', 'create-state-machine',
56
+ { definition: load_definition(name), name: name, 'role-arn': ROLE }, 'stateMachineArn')
57
+ raise Sf::DefinitionError if arn.empty?
58
+
59
+ Collection.instance.add(to_hash)
60
+ arn
61
+ end
62
+
63
+ def load_definition(_name)
64
+ local_definition_path = Tempfile.new(['name', '.json']).path
65
+ remote_definition_path = "#{Sfn.configuration.definition_path}/#{name}.json"
66
+
67
+ definition = File.read(remote_definition_path)
68
+ local_definition = definition.gsub(/"MaxConcurrency": [0-9]+/, '"MaxConcurrency": 1')
69
+
70
+ File.open(local_definition_path, 'w') { |file| file.puts local_definition }
71
+ "file://#{local_definition_path}"
72
+ end
73
+ end
74
+ end
data/lib/sfn.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'sfn/configuration'
6
+ require 'sfn/aws_cli'
7
+ require 'sfn/collection'
8
+ require 'sfn/state_machine'
9
+ require 'sfn/mock_data'
10
+ require 'sfn/mock_macros'
11
+ require 'sfn/execution'
12
+ require 'sfn/execution_log'
13
+ require 'string'
data/lib/string.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class String
4
+ def camelize
5
+ split(/[^a-z0-9]/i).collect(&:capitalize).join
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-sfn-local
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - Gianni Mazza
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-05-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake-release
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.11'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.11'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.28'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.28'
55
+ description: A convenient gem to test step function locally
56
+ email: gianni.mazza81rg@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - lib/sfn.rb
62
+ - lib/sfn/aws_cli.rb
63
+ - lib/sfn/collection.rb
64
+ - lib/sfn/configuration.rb
65
+ - lib/sfn/execution.rb
66
+ - lib/sfn/execution_log.rb
67
+ - lib/sfn/mock_data.rb
68
+ - lib/sfn/mock_macros.rb
69
+ - lib/sfn/mock_macros/api_gateway.rb
70
+ - lib/sfn/mock_macros/lambda.rb
71
+ - lib/sfn/mock_macros/sns.rb
72
+ - lib/sfn/mock_macros/step_function.rb
73
+ - lib/sfn/state_machine.rb
74
+ - lib/string.rb
75
+ homepage: https://github.com/redvex/ruby-sfn-local
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ rubygems_mfa_required: 'true'
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 2.7.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.1.2
96
+ signing_key:
97
+ specification_version: 4
98
+ summary: Step Functions local ruby wrapper!
99
+ test_files: []