yggdrasil-engine 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 60e31c7c12dd84fafcc5c0fa02061cc577d6ca9bb6a061eba51b3290cfd0a000
4
+ data.tar.gz: 26dcc66f90912709b60b918c2522ca1257a9d8c6907a4895ce01a471c3dbaa7d
5
+ SHA512:
6
+ metadata.gz: ca3263a27a8780d064a44ce49c127cbb13ad8c7d340d8267481d4c1d6b9a15f1b17dca25032e7b6888e210b3f14cdd89081bf0e409ca42aaddcbb71456b0d89f
7
+ data.tar.gz: 45540c794984350d0c6cf4a03baa436715f775a017b0dfc8f21c06a064f72262a98496dd528e08c745f7fd367c2737b3492fef37c844aaa7ade02671fc213b2c
data/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # Ruby Bindings to Yggdrasil
2
+
3
+ ## Running the tests
4
+
5
+ First make sure that you have built the native FFI code and it's located in the right place. This can be done with the build script in `build.sh`:
6
+
7
+
8
+ ```bash
9
+ ./build.sh
10
+ ```
11
+
12
+ Then you can run the tests with:
13
+
14
+ ```bash
15
+ rspec
16
+ ```
17
+
18
+ There's also a `mem_check.rb` in the scripts folder. This is not a bullet proof test, but it can be helpful for detecting large leaks. This requires human interaction - you need to read the output and understand what it's telling you, so it's not run as part of the test suite.
19
+
20
+ ```bash
21
+ ruby scripts/mem_check.rb
22
+ ```
23
+
24
+ ## Build
25
+
26
+ You can build the gem with:
27
+
28
+ ```bash
29
+ gem build yggdrasil-engine.gemspec
30
+ ```
31
+
32
+ Then you can install the gem for local development with:
33
+
34
+ ```
35
+ gem install yggdrasil-engine-0.0.1.gem
36
+ ```
@@ -0,0 +1,60 @@
1
+ STANDARD_STRATEGIES = [
2
+ "default",
3
+ "userWithId",
4
+ "gradualRolloutUserId",
5
+ "gradualRolloutSessionId",
6
+ "gradualRolloutRandom",
7
+ "flexibleRollout",
8
+ "remoteAddress",
9
+ ].freeze
10
+
11
+ class CustomStrategyHandler
12
+ def initialize
13
+ @custom_strategies_definitions = {}
14
+ @custom_strategy_implementations = {}
15
+ end
16
+
17
+ def update_strategies(json_str)
18
+ custom_strategies = {}
19
+ parsed_json = JSON.parse(json_str)
20
+
21
+ parsed_json["features"].each do |feature|
22
+ toggle_name = feature["name"]
23
+ strategies = feature["strategies"]
24
+
25
+ custom_strategies_for_toggle = strategies.select do |strategy|
26
+ !STANDARD_STRATEGIES.include?(strategy["name"])
27
+ end
28
+
29
+ unless custom_strategies_for_toggle.empty?
30
+ custom_strategies[toggle_name] = custom_strategies_for_toggle
31
+ end
32
+ end
33
+
34
+ @custom_strategies_definitions = custom_strategies
35
+ end
36
+
37
+ def register_custom_strategies(strategies)
38
+ strategies.each do |strategy|
39
+ if strategy.respond_to?(:name) && strategy.name.is_a?(String) &&
40
+ strategy.respond_to?(:enabled?)
41
+ @custom_strategy_implementations[strategy.name] = strategy
42
+ else
43
+ raise "Invalid strategy object. Must have a name method that returns a String and an enabled? method."
44
+ end
45
+ end
46
+ end
47
+
48
+ def evaluate_custom_strategies(toggle_name, context)
49
+ results = {}
50
+
51
+ @custom_strategies_definitions[toggle_name]&.each_with_index do |strategy, index|
52
+ key = "customStrategy#{index + 1}"
53
+ strategy_impl = @custom_strategy_implementations[strategy["name"]]
54
+ result = strategy_impl&.enabled?(strategy["parameters"], context) || false
55
+ results[key] = result
56
+ end
57
+
58
+ results
59
+ end
60
+ end
Binary file
@@ -0,0 +1,114 @@
1
+ require 'ffi'
2
+ require 'json'
3
+ require 'custom_strategy'
4
+
5
+ TOGGLE_MISSING_RESPONSE = 'NotFound'.freeze
6
+ ERROR_RESPONSE = 'Error'.freeze
7
+ OK_RESPONSE = 'Ok'.freeze
8
+
9
+ def platform_specific_lib
10
+ case RbConfig::CONFIG['host_os']
11
+ when /darwin|mac os/
12
+ 'libyggdrasilffi.dylib'
13
+ when /linux/
14
+ 'libyggdrasilffi.so'
15
+ when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
16
+ 'libyggdrasilffi.dll'
17
+ else
18
+ raise "unsupported platform #{RbConfig::CONFIG['host_os']}"
19
+ end
20
+ end
21
+
22
+ def to_variant(raw_variant)
23
+ payload = raw_variant[:payload] && raw_variant[:payload].transform_keys(&:to_s)
24
+ {
25
+ name: raw_variant[:name],
26
+ enabled: raw_variant[:enabled],
27
+ payload: payload,
28
+ }
29
+ end
30
+
31
+ class YggdrasilEngine
32
+ extend FFI::Library
33
+ ffi_lib File.expand_path(platform_specific_lib, __dir__)
34
+
35
+ attach_function :new_engine, [], :pointer
36
+ attach_function :free_engine, [:pointer], :void
37
+
38
+ attach_function :take_state, %i[pointer string], :pointer
39
+ attach_function :check_enabled, %i[pointer string string string], :pointer
40
+ attach_function :check_variant, %i[pointer string string string], :pointer
41
+ attach_function :get_metrics, [:pointer], :pointer
42
+ attach_function :free_response, [:pointer], :void
43
+
44
+ attach_function :count_toggle, %i[pointer string bool], :void
45
+ attach_function :count_variant, %i[pointer string string], :void
46
+
47
+ def initialize
48
+ @engine = YggdrasilEngine.new_engine
49
+ @custom_strategy_handler = CustomStrategyHandler.new
50
+ ObjectSpace.define_finalizer(self, self.class.finalize(@engine))
51
+ end
52
+
53
+ def self.finalize(engine)
54
+ proc { YggdrasilEngine.free_engine(engine) }
55
+ end
56
+
57
+ def take_state(toggles)
58
+ @custom_strategy_handler.update_strategies(toggles)
59
+ response_ptr = YggdrasilEngine.take_state(@engine, toggles)
60
+ take_toggles_response = JSON.parse(response_ptr.read_string, symbolize_names: true)
61
+ YggdrasilEngine.free_response(response_ptr)
62
+ end
63
+
64
+ def get_variant(name, context)
65
+ context_json = (context || {}).to_json
66
+ custom_strategy_results = @custom_strategy_handler.evaluate_custom_strategies(name, context).to_json
67
+
68
+ variant_def_json_ptr = YggdrasilEngine.check_variant(@engine, name, context_json, custom_strategy_results)
69
+ variant_def_json = variant_def_json_ptr.read_string
70
+ YggdrasilEngine.free_response(variant_def_json_ptr)
71
+ variant_response = JSON.parse(variant_def_json, symbolize_names: true)
72
+
73
+ return nil if variant_response[:status_code] == TOGGLE_MISSING_RESPONSE
74
+ variant = variant_response[:value]
75
+
76
+ return to_variant(variant) if variant_response[:status_code] == OK_RESPONSE
77
+ end
78
+
79
+ def enabled?(toggle_name, context)
80
+ context_json = (context || {}).to_json
81
+ custom_strategy_results = @custom_strategy_handler.evaluate_custom_strategies(toggle_name, context).to_json
82
+
83
+ response_ptr = YggdrasilEngine.check_enabled(@engine, toggle_name, context_json, custom_strategy_results)
84
+ response_json = response_ptr.read_string
85
+ YggdrasilEngine.free_response(response_ptr)
86
+ response = JSON.parse(response_json, symbolize_names: true)
87
+
88
+ raise "Error: #{response[:error_message]}" if response[:status_code] == ERROR_RESPONSE
89
+ return nil if response[:status_code] == TOGGLE_MISSING_RESPONSE
90
+
91
+ return response[:value] == true
92
+ end
93
+
94
+ def count_toggle(toggle_name, enabled)
95
+ response_ptr = YggdrasilEngine.count_toggle(@engine, toggle_name, enabled)
96
+ YggdrasilEngine.free_response(response_ptr)
97
+ end
98
+
99
+ def count_variant(toggle_name, variant_name)
100
+ response_ptr = YggdrasilEngine.count_variant(@engine, toggle_name, variant_name)
101
+ YggdrasilEngine.free_response(response_ptr)
102
+ end
103
+
104
+ def get_metrics
105
+ metrics_ptr = YggdrasilEngine.get_metrics(@engine)
106
+ metrics = JSON.parse(metrics_ptr.read_string, symbolize_names: true)
107
+ YggdrasilEngine.free_response(metrics_ptr)
108
+ metrics[:value]
109
+ end
110
+
111
+ def register_custom_strategies(strategies)
112
+ @custom_strategy_handler.register_custom_strategies(strategies)
113
+ end
114
+ end
@@ -0,0 +1,136 @@
1
+ require_relative '../lib/custom_strategy'
2
+
3
+ RSpec.describe "custom strategies" do
4
+ let(:raw_state) do
5
+ {
6
+ "version": 1,
7
+ "features": [
8
+ {
9
+ "name": "Feature.A",
10
+ "enabled": true,
11
+ "strategies": [
12
+ {
13
+ "name": "default",
14
+ "parameters": {}
15
+ },
16
+ {
17
+ "name": "custom",
18
+ "parameters": {
19
+ "gerkhins": "yes"
20
+ }
21
+ },
22
+ {
23
+ "name": "some-other-custom",
24
+ "parameters": {
25
+ "gerkhins": "yes"
26
+ }
27
+ },
28
+ ]
29
+ }
30
+ ]
31
+ }
32
+ end
33
+
34
+ let(:handler) { CustomStrategyHandler.new }
35
+
36
+ before do
37
+ handler.update_strategies(raw_state.to_json)
38
+ end
39
+
40
+ describe 'computing custom strategies' do
41
+ it 'respects the logic contained in the enabled function' do
42
+ class TestStrategy
43
+ attr_reader :name
44
+
45
+ def initialize(name)
46
+ @name = name
47
+ end
48
+
49
+ def enabled?(params, context)
50
+ params["gerkhins"] == "yes"
51
+ end
52
+ end
53
+
54
+ handler.register_custom_strategies([TestStrategy.new("custom")])
55
+ strategy_results = handler.evaluate_custom_strategies("Feature.A", {})
56
+ expect(strategy_results.length).to eq(2)
57
+ expect(strategy_results["customStrategy1"]).to eq(true)
58
+ expect(strategy_results["customStrategy2"]).to eq(false)
59
+ end
60
+
61
+ it 'creates a strategy result for every custom strategy thats implemented and defined' do
62
+ class TestStrategy
63
+ attr_reader :name
64
+
65
+ def initialize(name)
66
+ @name = name
67
+ end
68
+
69
+ def enabled?(params, context)
70
+ params["gerkhins"] == "yes"
71
+ end
72
+ end
73
+
74
+ handler.register_custom_strategies([TestStrategy.new("custom"), TestStrategy.new("some-other-custom")])
75
+ strategy_results = handler.evaluate_custom_strategies("Feature.A", {})
76
+ expect(strategy_results.length).to eq(2)
77
+ expect(strategy_results["customStrategy1"]).to eq(true)
78
+ expect(strategy_results["customStrategy2"]).to eq(true)
79
+ end
80
+
81
+ it 'returns false for missing implementations' do
82
+ handler.register_custom_strategies([])
83
+ strategy_results = handler.evaluate_custom_strategies("Feature.A", {})
84
+ expect(strategy_results.length).to eq(2)
85
+ expect(strategy_results["customStrategy1"]).to eq(false)
86
+ end
87
+
88
+ it "should calculate custom strategies e2e" do
89
+ class TestStrategy
90
+ attr_reader :name
91
+
92
+ def initialize(name)
93
+ @name = name
94
+ end
95
+
96
+ def enabled?(params, context)
97
+ context[:userId] == "123"
98
+ end
99
+ end
100
+
101
+ state = {
102
+ "version": 1,
103
+ "features": [
104
+ {
105
+ "name": "Feature.A",
106
+ "enabled": true,
107
+ "strategies": [
108
+ {
109
+ "name": "custom",
110
+ "parameters": {
111
+ "gerkhins": "yes"
112
+ }
113
+ }
114
+ ]
115
+ }
116
+ ]
117
+ }
118
+
119
+ engine = YggdrasilEngine.new
120
+ engine.register_custom_strategies([TestStrategy.new("custom")])
121
+
122
+ engine.take_state(state.to_json)
123
+
124
+ should_be_enabled = engine.enabled?("Feature.A", {
125
+ userId: "123"
126
+ })
127
+
128
+ should_not_be_enabled = engine.enabled?("Feature.A", {
129
+ userId: "456"
130
+ })
131
+
132
+ expect(should_be_enabled).to eq(true)
133
+ expect(should_not_be_enabled).to eq(false)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,139 @@
1
+ require 'rspec'
2
+ require 'json'
3
+ require_relative '../lib/yggdrasil_engine'
4
+
5
+ index_file_path = '../client-specification/specifications/index.json'
6
+ test_suites = JSON.parse(File.read(index_file_path))
7
+
8
+ RSpec.describe YggdrasilEngine do
9
+ let(:yggdrasil_engine) { YggdrasilEngine.new }
10
+
11
+ describe '#checking a toggle' do
12
+ it 'that does not exist should yield a not found' do
13
+ is_enabled = yggdrasil_engine.enabled?('missing-toggle', {})
14
+ expect(is_enabled).to be_nil
15
+ end
16
+ end
17
+
18
+ describe '#metrics' do
19
+ it 'should clear metrics when get_metrics is called' do
20
+ feature_name = 'Feature.A'
21
+
22
+ suite_path = File.join('../client-specification/specifications', '01-simple-examples.json')
23
+ suite_data = JSON.parse(File.read(suite_path))
24
+
25
+ yggdrasil_engine.take_state(suite_data['state'].to_json)
26
+
27
+ yggdrasil_engine.count_toggle(feature_name, true)
28
+ yggdrasil_engine.count_toggle(feature_name, false)
29
+
30
+ metrics = yggdrasil_engine.get_metrics() # This should clear the metrics buffer
31
+
32
+ metric = metrics[:toggles][feature_name.to_sym]
33
+ expect(metric[:yes]).to eq(1)
34
+ expect(metric[:no]).to eq(1)
35
+
36
+ metrics = yggdrasil_engine.get_metrics()
37
+ expect(metrics).to be_nil
38
+ end
39
+
40
+ it 'should increment toggle count when it exists' do
41
+ toggle_name = 'Feature.A'
42
+
43
+ suite_path = File.join('../client-specification/specifications', '01-simple-examples.json')
44
+ suite_data = JSON.parse(File.read(suite_path))
45
+
46
+ yggdrasil_engine.take_state(suite_data['state'].to_json)
47
+
48
+ yggdrasil_engine.count_toggle(toggle_name, true)
49
+ yggdrasil_engine.count_toggle(toggle_name, false)
50
+
51
+ metrics = yggdrasil_engine.get_metrics()
52
+ metric = metrics[:toggles][toggle_name.to_sym]
53
+
54
+ expect(metric[:yes]).to eq(1)
55
+ expect(metric[:no]).to eq(1)
56
+ end
57
+
58
+ it 'should increment toggle count when the toggle does not exist' do
59
+ toggle_name = 'Feature.X'
60
+
61
+ yggdrasil_engine.count_toggle(toggle_name, true)
62
+ yggdrasil_engine.count_toggle(toggle_name, false)
63
+
64
+ metrics = yggdrasil_engine.get_metrics()
65
+ metric = metrics[:toggles][toggle_name.to_sym]
66
+
67
+ expect(metric[:yes]).to eq(1)
68
+ expect(metric[:no]).to eq(1)
69
+ end
70
+
71
+ it 'should increment variant' do
72
+ toggle_name = 'Feature.Q'
73
+
74
+ suite_path = File.join('../client-specification/specifications', '01-simple-examples.json')
75
+ suite_data = JSON.parse(File.read(suite_path))
76
+
77
+ yggdrasil_engine.take_state(suite_data['state'].to_json)
78
+
79
+ yggdrasil_engine.count_variant(toggle_name, 'disabled')
80
+
81
+ metrics = yggdrasil_engine.get_metrics()
82
+ metric = metrics[:toggles][toggle_name.to_sym]
83
+
84
+ expect(metric[:variants][:disabled]).to eq(1)
85
+ end
86
+ end
87
+ end
88
+
89
+ RSpec.describe 'Client Specification' do
90
+ let(:yggdrasil_engine) { YggdrasilEngine.new }
91
+
92
+ test_suites.each do |suite|
93
+ suite_path = File.join('../client-specification/specifications', suite)
94
+ suite_data = JSON.parse(File.read(suite_path), symbolize_names: true)
95
+
96
+ describe "Suite '#{suite}'" do
97
+ before(:each) do
98
+ yggdrasil_engine.take_state(suite_data[:state].to_json)
99
+ end
100
+
101
+ suite_data.fetch(:tests, []).each do |test|
102
+ describe "Test '#{test[:description]}'" do
103
+ let(:context) { test[:context] }
104
+ let(:toggle_name) { test[:toggleName] }
105
+ let(:expected_result) { test[:expectedResult] }
106
+
107
+ it 'returns correct result for `is_enabled?` method' do
108
+ result = yggdrasil_engine.enabled?(toggle_name, context) || false
109
+
110
+ expect(result).to eq(expected_result),
111
+ "Failed test '#{test['description']}': expected #{expected_result}, got #{result}"
112
+ end
113
+ end
114
+ end
115
+
116
+ suite_data.fetch(:variantTests, []).each do |test|
117
+ next unless test[:expectedResult]
118
+
119
+ describe "Variant Test '#{test[:description]}'" do
120
+ let(:context) { test[:context] }
121
+ let(:toggle_name) { test[:toggleName] }
122
+ let(:expected_result) { to_variant(test[:expectedResult]) }
123
+
124
+ it 'returns correct result for `get_variant` method' do
125
+ result = yggdrasil_engine.get_variant(toggle_name, context) || {
126
+ :name => 'disabled',
127
+ :payload => nil,
128
+ :enabled => false
129
+ }
130
+
131
+ expect(result[:name]).to eq(expected_result[:name])
132
+ expect(result[:payload]).to eq(expected_result[:payload])
133
+ expect(result[:enabled]).to eq(expected_result[:enabled])
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yggdrasil-engine
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Your Name
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-06-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ffi
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.15.5
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.15.5
27
+ - !ruby/object:Gem::Dependency
28
+ name: fiddle
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.0.6
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.0.6
41
+ description: "..."
42
+ email: you@example.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - README.md
48
+ - lib/custom_strategy.rb
49
+ - lib/libyggdrasilffi.so
50
+ - lib/yggdrasil_engine.rb
51
+ - spec/custom_strategy_spec.rb
52
+ - spec/yggdrasil_engine_spec.rb
53
+ homepage: http://github.com/username/my_gem
54
+ licenses:
55
+ - MIT
56
+ metadata: {}
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.4.10
73
+ signing_key:
74
+ specification_version: 4
75
+ summary: Unleash engine for evaluating feature toggles
76
+ test_files: []