summoner-engine 0.1.0

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/MIT-LICENSE +20 -0
  3. data/README.md +205 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/images/summoner/favicon.ico +0 -0
  6. data/app/assets/stylesheets/summoner/application.css +15 -0
  7. data/app/controllers/summoner/application_controller.rb +7 -0
  8. data/app/controllers/summoner/features_controller.rb +40 -0
  9. data/app/controllers/summoner/overrides_controller.rb +64 -0
  10. data/app/helpers/summoner/application_helper.rb +4 -0
  11. data/app/jobs/summoner/application_job.rb +4 -0
  12. data/app/mailers/summoner/application_mailer.rb +6 -0
  13. data/app/models/summoner/application_record.rb +5 -0
  14. data/app/models/summoner/feature.rb +61 -0
  15. data/app/models/summoner/feature_override.rb +22 -0
  16. data/app/views/layouts/summoner/application.html.erb +139 -0
  17. data/app/views/summoner/features/edit.html.erb +38 -0
  18. data/app/views/summoner/features/index.html.erb +55 -0
  19. data/app/views/summoner/features/show.html.erb +120 -0
  20. data/app/views/summoner/overrides/edit.html.erb +37 -0
  21. data/config/routes.rb +7 -0
  22. data/lib/generators/summoner/install/install_generator.rb +53 -0
  23. data/lib/generators/summoner/install/templates/create_summoner_tables.rb.erb +28 -0
  24. data/lib/generators/summoner/install/templates/features.yml +26 -0
  25. data/lib/generators/summoner/install/templates/summoner.rb +10 -0
  26. data/lib/summoner/configuration.rb +20 -0
  27. data/lib/summoner/engine.rb +9 -0
  28. data/lib/summoner/entity.rb +49 -0
  29. data/lib/summoner/fetcher.rb +66 -0
  30. data/lib/summoner/loader.rb +0 -0
  31. data/lib/summoner/sync.rb +102 -0
  32. data/lib/summoner/version.rb +3 -0
  33. data/lib/summoner.rb +11 -0
  34. data/lib/tasks/summoner_tasks.rake +20 -0
  35. metadata +108 -0
@@ -0,0 +1,55 @@
1
+ <div class="d-flex justify-content-between align-items-center mb-4">
2
+ <h2 class="mb-0" style="font-size: 1.5rem;">All Features</h2>
3
+ </div>
4
+
5
+ <div class="card">
6
+ <table>
7
+ <thead>
8
+ <tr>
9
+ <th>Namespace</th>
10
+ <th>Key</th>
11
+ <th>Description</th>
12
+ <th>Type</th>
13
+ <th>Default</th>
14
+ <th>Status</th>
15
+ <th>Actions</th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @features.each do |feature| %>
20
+ <tr>
21
+ <td><strong><%= feature.namespace %></strong></td>
22
+ <td><%= feature.name %></td>
23
+ <td title="<%= feature.description %>"><%= feature.description.present? ? truncate(feature.description, length: 55) : "-" %></td>
24
+ <td>
25
+ <div class="d-flex align-items-center flex-wrap gap-2">
26
+ <code><%= feature.value_type %></code>
27
+ <% if feature.match_attribute.present? %>
28
+ <span title="Matches by: <%= feature.match_attribute %>" style="font-size: 0.7rem; color: var(--text-muted); background: var(--bg-main); padding: 0.15rem 0.4rem; border-radius: 4px; border: 1px solid var(--border-color); white-space: nowrap;">
29
+ <span style="opacity: 0.6;">match:</span> <%= feature.match_attribute %>
30
+ </span>
31
+ <% end %>
32
+ </div>
33
+ </td>
34
+ <td><code><%= feature.default_value.to_json %></code></td>
35
+ <td>
36
+ <span class="badge <%= feature.active ? 'active' : 'inactive' %>">
37
+ <%= feature.active ? 'Active' : 'Inactive (Removed)' %>
38
+ </span>
39
+ </td>
40
+ <td>
41
+ <%= link_to "Details", feature_path(feature), class: "btn btn-outline btn-sm", style: "font-size: 0.75rem; text-decoration: none;" %>
42
+ </td>
43
+ </tr>
44
+ <% end %>
45
+
46
+ <% if @features.empty? %>
47
+ <tr>
48
+ <td colspan="7" class="text-center text-muted" style="padding: 3rem;">
49
+ No features found. Make sure you have run the YAML sync.
50
+ </td>
51
+ </tr>
52
+ <% end %>
53
+ </tbody>
54
+ </table>
55
+ </div>
@@ -0,0 +1,120 @@
1
+ <div class="mb-4">
2
+ <%= link_to "← Back to features", features_path, class: "btn btn-outline btn-sm", style: "text-decoration: none;" %>
3
+ </div>
4
+
5
+ <div class="card">
6
+ <div class="card-header d-flex justify-content-between align-items-center">
7
+ <h2 class="mb-0"><%= @feature.namespace %> / <%= @feature.name %></h2>
8
+ <div class="d-flex align-items-center gap-2">
9
+ <% if @feature.last_yaml_value.present? && @feature.default_value != @feature.last_yaml_value %>
10
+ <span class="text-muted" style="font-size: 0.75rem; margin-right: 0.5rem;">
11
+ (Diverges from YAML: <code><%= @feature.last_yaml_value %></code>)
12
+ </span>
13
+ <% end %>
14
+ <%= link_to "Edit Default", edit_feature_path(@feature), class: "btn btn-outline btn-sm" %>
15
+ </div>
16
+ </div>
17
+
18
+ <div class="card-body">
19
+ <div class="grid-details">
20
+ <span>Key:</span> <code><%= @feature.key %></code>
21
+ <span>Description:</span> <span><%= @feature.description.presence || "No description set" %></span>
22
+ <span>Type:</span> <code><%= @feature.value_type %></code>
23
+
24
+ <% if @feature.match_attribute.present? %>
25
+ <span>Matches Attribute:</span> <code><%= @feature.match_attribute %></code>
26
+ <% end %>
27
+
28
+ <span>Default Value:</span> <code><%= @feature.default_value.to_json %></code>
29
+ <span>Status:</span>
30
+ <div>
31
+ <span class="badge <%= @feature.active ? 'active' : 'inactive' %>">
32
+ <%= @feature.active ? 'Active' : 'Removed from YAML' %>
33
+ </span>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="card">
40
+ <div class="card-header">
41
+ <h3 class="mb-0">Overrides (Specific Rules)</h3>
42
+ </div>
43
+
44
+ <table>
45
+ <thead>
46
+ <tr>
47
+ <th>Entity</th>
48
+ <th>ID</th>
49
+ <th>Overridden Value</th>
50
+ <th>Actions</th>
51
+ </tr>
52
+ </thead>
53
+ <tbody>
54
+ <% @overrides.each do |override| %>
55
+ <tr>
56
+ <td><%= override.flaggable_type %></td>
57
+ <td><%= override.flaggable_id %></td>
58
+ <td><code><%= override.value.to_json %></code></td>
59
+ <td>
60
+ <div class="d-flex gap-2 align-items-center">
61
+ <%= button_to "Edit", edit_feature_override_path(@feature, override),
62
+ method: :get,
63
+ form: { class: "mb-0" },
64
+ class: "btn btn-primary btn-sm" %>
65
+
66
+ <%= button_to "Remove", feature_override_path(@feature, override),
67
+ method: :delete,
68
+ data: { confirm: "Are you sure?" },
69
+ form: { class: "mb-0" },
70
+ class: "btn btn-danger btn-sm" %>
71
+ </div>
72
+ </td>
73
+ </tr>
74
+ <% end %>
75
+
76
+ <% if @overrides.empty? %>
77
+ <tr>
78
+ <td colspan="4" class="text-center text-muted" style="padding: 3rem;">
79
+ No overrides found for this feature.
80
+ </td>
81
+ </tr>
82
+ <% end %>
83
+ </tbody>
84
+ </table>
85
+ </div>
86
+
87
+ <div class="card">
88
+ <div class="card-header">
89
+ <h3 class="mb-0">Add New Override</h3>
90
+ </div>
91
+ <div class="card-body">
92
+ <%= form_with model: Summoner::FeatureOverride.new, url: feature_overrides_path(@feature), local: true do |f| %>
93
+ <div class="d-flex gap-3 align-items-end flex-wrap">
94
+
95
+ <div class="form-group mb-0" style="width: 140px;">
96
+ <%= f.label :flaggable_type, "Model", class: "form-label" %>
97
+ <%= f.text_field :flaggable_type, value: @feature.namespace.classify, readonly: true, class: "form-control" %>
98
+ </div>
99
+
100
+ <div class="form-group mb-0" style="width: 100px;">
101
+ <%= f.label :flaggable_id, "ID", class: "form-label" %>
102
+ <%= f.number_field :flaggable_id, placeholder: "1", required: true, class: "form-control" %>
103
+ </div>
104
+
105
+ <div class="form-group mb-0" style="width: 200px;">
106
+ <%= f.label :value, "Value (#{@feature.value_type})", class: "form-label" %>
107
+ <% if @feature.value_type == 'boolean' %>
108
+ <%= f.select :value, [['True', 'true'], ['False', 'false']], {}, class: "form-control" %>
109
+ <% else %>
110
+ <%= f.text_field :value, placeholder: @feature.default_value, required: true, class: "form-control" %>
111
+ <% end %>
112
+ </div>
113
+
114
+ <div class="form-group mb-0">
115
+ <%= f.submit "Save Override", class: "btn btn-primary" %>
116
+ </div>
117
+ </div>
118
+ <% end %>
119
+ </div>
120
+ </div>
@@ -0,0 +1,37 @@
1
+ <div class="mb-4">
2
+ <%= link_to "← Back to Feature", feature_path(@feature), class: "btn btn-outline btn-sm", style: "text-decoration: none;" %>
3
+ </div>
4
+
5
+ <div class="card" style="max-width: 600px;">
6
+ <div class="card-header">
7
+ <h2 class="mb-0">Edit Override</h2>
8
+ </div>
9
+
10
+ <div class="card-body">
11
+ <div class="grid-details mb-4">
12
+ <span>Feature:</span> <code><%= @feature.key %></code>
13
+ <span>Entity:</span> <span><%= @override.flaggable_type %> (ID: <%= @override.flaggable_id %>)</span>
14
+ <span>Expected Type:</span> <code><%= @feature.value_type %></code>
15
+
16
+ <% if @feature.match_attribute.present? %>
17
+ <span>Matches Attribute:</span> <code style="background: #fef08a; color: #854d0e;"><%= @feature.match_attribute %></code>
18
+ <% end %>
19
+ </div>
20
+
21
+ <hr style="border: 0; border-top: 1px solid var(--border-color); margin: 1.5rem 0;">
22
+
23
+ <%= form_with model: @override, url: feature_override_path(@feature, @override), method: :patch, local: true do |f| %>
24
+ <div class="form-group mb-4">
25
+ <%= f.label :value, "New Value", class: "form-label" %>
26
+
27
+ <% if @feature.value_type == 'boolean' %>
28
+ <%= f.select :value, [['True', 'true'], ['False', 'false']], { selected: @override.value.to_s }, class: "form-control" %>
29
+ <% else %>
30
+ <%= f.text_field :value, value: @override.value.is_a?(String) ? @override.value : @override.value.to_json, class: "form-control" %>
31
+ <% end %>
32
+ </div>
33
+
34
+ <%= f.submit "Update Value", class: "btn btn-primary" %>
35
+ <% end %>
36
+ </div>
37
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ Summoner::Engine.routes.draw do
2
+ root to: 'features#index'
3
+
4
+ resources :features, only: %i[index show edit update] do
5
+ resources :overrides, only: %i[create edit update destroy]
6
+ end
7
+ end
@@ -0,0 +1,53 @@
1
+ require 'rails/generators/base'
2
+ require 'rails/generators/active_record'
3
+
4
+ module Summoner
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ desc 'Installs Summoner: creates initializer, features.yml template and migration for features and overrides tables.'
12
+
13
+ def create_initializer
14
+ say 'Creating initializer...', :green
15
+ copy_file 'summoner.rb', 'config/initializers/summoner.rb'
16
+ end
17
+
18
+ def create_features_yml
19
+ say 'Creating features.yml template...', :green
20
+ copy_file 'features.yml', 'config/features.yml'
21
+ end
22
+
23
+ def copy_migrations
24
+ say 'Creating migration...', :green
25
+ migration_template 'create_summoner_tables.rb.erb', 'db/migrate/create_summoner_tables.rb'
26
+ end
27
+
28
+ def show_readme
29
+ readme_text = <<~README
30
+
31
+ ==============================================================================
32
+ Welcome to...
33
+
34
+ ██████ ▄▄ ▄▄ ▄▄▄▄▄ ▄█████ ▄▄ ▄▄ ▄▄ ▄▄ ▄▄ ▄▄ ▄▄▄ ▄▄ ▄▄ ▄▄ ▄▄ ▄▄ ▄▄▄▄
35
+ ██ ██▄██ ██▄▄ ▀▀▀▄▄▄ ██ ██ ██▀▄▀██ ██▀▄▀██ ██▀██ ███▄██ ██ ███▄██ ██ ▄▄
36
+ ██ ██ ██ ██▄▄▄ █████▀ ▀███▀ ██ ██ ██ ██ ▀███▀ ██ ▀██ ██ ██ ▀██ ▀███▀
37
+
38
+ Next steps:
39
+ 1. Run `rails db:migrate` to create the tables.
40
+ 2. Edit your new `config/features.yml` file as desired.
41
+ 3. Run `rails summoner:sync` to save the features to the database!
42
+ ==============================================================================
43
+ README
44
+
45
+ puts readme_text
46
+ end
47
+
48
+ def self.next_migration_number(dirname)
49
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,28 @@
1
+ class CreateSummonerTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :summoner_features do |t|
4
+ t.string :key, null: false
5
+ t.string :namespace, null: false
6
+ t.string :name, null: false
7
+ t.string :value_type, null: false
8
+ t.json :default_value
9
+ t.json :last_yaml_value
10
+ t.text :description
11
+ t.text :last_yaml_description
12
+ t.string :match_attribute
13
+ t.boolean :active, default: true
14
+ t.timestamps
15
+ end
16
+ add_index :summoner_features, :key, unique: true
17
+
18
+ create_table :summoner_feature_overrides do |t|
19
+ t.string :feature_key, null: false
20
+ t.references :flaggable, polymorphic: true, index: { name: 'idx_summoner_overrides_entity' }
21
+ t.json :value
22
+ t.timestamps
23
+ end
24
+ add_index :summoner_feature_overrides, [:feature_key, :flaggable_id, :flaggable_type],
25
+ unique: true,
26
+ name: 'idx_summoner_unique_override'
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ # =========================================================
2
+ # Summoner - Features Configuration
3
+ #
4
+ # After any changes to this file, run in the terminal:
5
+ # rails summoner:sync
6
+ # =========================================================
7
+
8
+ user:
9
+ # 1. Sample Boolean Feature
10
+ # Will be injected as `user.new_dashboard?`
11
+ new_dashboard:
12
+ default: false
13
+ description: Enable the new dashboard layout for users.
14
+
15
+ # 2. ABAC (Attribute-Based Access Control)
16
+ # The engine will match this array with the `role` attribute of the user.
17
+ # Tip: Use ["*"] to enable the feature globally for all users!
18
+ "can_access_admin?":
19
+ default: ["admin", "manager"]
20
+ match_attribute: "role"
21
+ description: Allow admins and managers to access the admin area.
22
+
23
+ # 3. Features of Value (Integer, Float, Json, String)
24
+ max_items_per_page:
25
+ default: 20
26
+ description: Maximum number of items shown per page.
@@ -0,0 +1,10 @@
1
+ Summoner.configure do |config|
2
+ # Enable or disable the use of Rails.cache for features (Default: true)
3
+ # config.cache_enabled = true
4
+
5
+ # Expiration time for the cache (Default: 1.hour)
6
+ # config.cache_expires_in = 1.hour
7
+
8
+ # Namespace for cache keys (Default: 'summoner')
9
+ # config.cache_namespace = 'summoner'
10
+ end
@@ -0,0 +1,20 @@
1
+ module Summoner
2
+ class Configuration
3
+ attr_accessor :cache_enabled, :cache_namespace, :cache_expires_in
4
+
5
+ def initialize
6
+ @cache_enabled = true
7
+ @cache_namespace = 'summoner'
8
+ @cache_expires_in = 1.hour
9
+ end
10
+ end
11
+
12
+ def self.configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def self.configure
17
+ yield(configuration)
18
+ configuration
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ module Summoner
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Summoner
4
+
5
+ initializer "summoner.assets.precompile" do |app|
6
+ app.config.assets.precompile += %w( summoner/favicon.ico )
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,49 @@
1
+ require 'active_support/concern'
2
+
3
+ module Summoner
4
+ module Entity
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :summoner_namespace
9
+
10
+ self.summoner_namespace = self.name.underscore
11
+
12
+ def self.inject_summoner_methods!
13
+ return unless ActiveRecord::Base.connection.table_exists?(Summoner::Feature.table_name)
14
+
15
+ features = Summoner::Feature.where(namespace: summoner_namespace)
16
+
17
+ features.each do |feat|
18
+ method_name = feat.value_type == 'boolean' ? "#{feat.name.chomp('?')}?" : feat.name
19
+
20
+ if method_defined?(method_name)
21
+ Rails.logger.warn "[Summoner] Method #{method_name} already defined in #{self.name}. Skipping feature #{feat.key}."
22
+ next
23
+ end
24
+
25
+ define_method(method_name) do
26
+ feature(feat.name)
27
+ end
28
+ end
29
+
30
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid => e
31
+ Rails.logger.warn "[Summoner] Could not inject feature methods for #{self.name}: #{e.message}. This may be due to the database not being set up yet. Skipping injection."
32
+ end
33
+
34
+ inject_summoner_methods!
35
+ end
36
+
37
+ class_methods do
38
+ def feature_namespace(name)
39
+ self.summoner_namespace = name.to_s
40
+ inject_summoner_methods!
41
+ end
42
+ end
43
+
44
+ def feature(name)
45
+ full_key = "#{self.class.summoner_namespace}.#{name}"
46
+ Summoner::Fetcher.value_for(full_key, entity: self)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,66 @@
1
+ module Summoner
2
+ class Fetcher
3
+ def self.value_for(feature_key, entity: nil)
4
+ config = Summoner.configuration
5
+
6
+ return fetch_from_db(feature_key, entity) unless config.cache_enabled
7
+
8
+ cache_key = if entity
9
+ "#{config.cache_namespace}:#{feature_key}:#{entity.class.name.underscore}:#{entity.id}"
10
+ else
11
+ "#{config.cache_namespace}:#{feature_key}"
12
+ end
13
+
14
+ Rails.cache.fetch(cache_key, expires_in: config.cache_expires_in) do
15
+ fetch_from_db(feature_key, entity)
16
+ end
17
+ end
18
+
19
+ def self.fetch_from_db(feature_key, entity)
20
+ feature = Summoner::Feature.find_by(key: feature_key)
21
+ return nil unless feature
22
+
23
+ raw_value = resolve_raw_value(feature, entity)
24
+
25
+ if feature.match_attribute.present? && entity.present?
26
+ return evaluate_match(raw_value, feature.match_attribute, entity)
27
+ end
28
+
29
+ raw_value
30
+ end
31
+
32
+ def self.resolve_raw_value(feature, entity)
33
+ if entity.present?
34
+ override = Summoner::FeatureOverride.find_by(
35
+ feature_key: feature.key,
36
+ flaggable: entity
37
+ )
38
+
39
+ return override.value if override.present?
40
+ end
41
+
42
+ feature.default_value
43
+ end
44
+
45
+ def self.evaluate_match(allowed_values, attribute, entity)
46
+ if allowed_values.is_a?(Array) && allowed_values.include?('*')
47
+ return true
48
+ elsif allowed_values == '*'
49
+ return true
50
+ end
51
+
52
+ unless entity.respond_to?(attribute)
53
+ Rails.logger.warn "[Summoner] Entity #{entity.class.name} does not respond to #{attribute}. Cannot evaluate match for feature #{feature.key}."
54
+ return false
55
+ end
56
+
57
+ entity_value = entity.public_send(attribute)
58
+
59
+ if allowed_values.is_a?(Array)
60
+ allowed_values.include?(entity_value)
61
+ else
62
+ allowed_values == entity_value
63
+ end
64
+ end
65
+ end
66
+ end
File without changes
@@ -0,0 +1,102 @@
1
+ require 'yaml'
2
+
3
+ module Summoner
4
+ class Sync
5
+ def self.call(yaml_path: nil)
6
+ yaml_path ||= Rails.root.join('config', 'features.yml')
7
+
8
+ unless File.exist?(yaml_path)
9
+ Rails.logger.warn "[Summoner] No features.yml file found at #{yaml_path}. Skipping synchronization."
10
+ return false
11
+ end
12
+
13
+ parsed_yaml = YAML.load_file(yaml_path) || {}
14
+
15
+ Summoner::Feature.transaction do
16
+ sync_feature(parsed_yaml)
17
+ deactivate_missing_features(parsed_yaml)
18
+ end
19
+
20
+ true
21
+ end
22
+
23
+ private
24
+
25
+ def self.sync_feature(parsed_yaml)
26
+ parsed_yaml.each do |namespace, features|
27
+ next unless features.is_a?(Hash)
28
+
29
+ features.each do |raw_name, config|
30
+ yaml_value = config['default']
31
+ yaml_description = config['description']
32
+ inferred_type = infer_type(yaml_value)
33
+ name = normalize_name(raw_name, inferred_type)
34
+ key = "#{namespace}.#{name}"
35
+ feature = Summoner::Feature.find_or_initialize_by(key: key)
36
+
37
+ feature.assign_attributes(
38
+ namespace: namespace,
39
+ name: name,
40
+ value_type: inferred_type,
41
+ match_attribute: config['match_attribute'],
42
+ active: true
43
+ )
44
+
45
+ if should_update_value?(feature, yaml_value)
46
+ feature.default_value = yaml_value
47
+ feature.last_yaml_value = yaml_value
48
+ end
49
+
50
+ if should_update_yaml_field?(feature, yaml_description, :last_yaml_description)
51
+ feature.description = yaml_description
52
+ feature.last_yaml_description = yaml_description
53
+ end
54
+
55
+ feature.save!
56
+ end
57
+ end
58
+ end
59
+
60
+ def self.should_update_value?(feature, yaml_value)
61
+ feature.new_record? || feature.last_yaml_value != yaml_value
62
+ end
63
+
64
+ def self.should_update_yaml_field?(feature, yaml_value, tracked_attribute)
65
+ feature.new_record? || feature.public_send(tracked_attribute) != yaml_value
66
+ end
67
+
68
+ def self.deactivate_missing_features(parsed_yaml)
69
+ active_keys = []
70
+
71
+ parsed_yaml.each do |namespace, features|
72
+ next unless features.is_a?(Hash)
73
+
74
+ features.each do |raw_name, config|
75
+ yaml_value = config['default']
76
+ inferred_type = infer_type(yaml_value)
77
+ name = normalize_name(raw_name, inferred_type)
78
+
79
+ active_keys << "#{namespace}.#{name}"
80
+ end
81
+ end
82
+
83
+ Summoner::Feature.where.not(key: active_keys).update_all(active: false)
84
+ end
85
+
86
+ def self.infer_type(value)
87
+ case value
88
+ when TrueClass, FalseClass then 'boolean'
89
+ when Integer then'integer'
90
+ when Float then 'float'
91
+ when Array, Hash then 'json'
92
+ else 'string'
93
+ end
94
+ end
95
+
96
+ def self.normalize_name(name, type)
97
+ name_str = name.to_s
98
+
99
+ type == 'boolean' ? "#{name_str.chomp('?')}?" : name_str
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,3 @@
1
+ module Summoner
2
+ VERSION = "0.1.0"
3
+ end
data/lib/summoner.rb ADDED
@@ -0,0 +1,11 @@
1
+ Dir[File.join(__dir__, "summoner", "*.rb")].sort.each { |file| require file }
2
+
3
+ module Summoner
4
+ def self.get(feature_key)
5
+ Summoner::Fetcher.value_for(feature_key)
6
+ end
7
+
8
+ def self.active?(feature_key)
9
+ !!get(feature_key)
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ namespace :summoner do
2
+ desc 'Sync the .yml file with the engine database'
3
+ task sync: :environment do
4
+ puts '[Summoner] Starting synchronization...'
5
+
6
+ sleep(2)
7
+
8
+ begin
9
+ result = Summoner::Sync.call
10
+
11
+ if result
12
+ puts '[Summoner] Features synchronized successfully.'
13
+ else
14
+ puts '[Summoner] Synchronization skipped due to missing .yml file.'
15
+ end
16
+ rescue => e
17
+ puts "[Summoner] An error occurred while synchronizing features: #{e.message}"
18
+ end
19
+ end
20
+ end