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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +205 -0
- data/Rakefile +6 -0
- data/app/assets/images/summoner/favicon.ico +0 -0
- data/app/assets/stylesheets/summoner/application.css +15 -0
- data/app/controllers/summoner/application_controller.rb +7 -0
- data/app/controllers/summoner/features_controller.rb +40 -0
- data/app/controllers/summoner/overrides_controller.rb +64 -0
- data/app/helpers/summoner/application_helper.rb +4 -0
- data/app/jobs/summoner/application_job.rb +4 -0
- data/app/mailers/summoner/application_mailer.rb +6 -0
- data/app/models/summoner/application_record.rb +5 -0
- data/app/models/summoner/feature.rb +61 -0
- data/app/models/summoner/feature_override.rb +22 -0
- data/app/views/layouts/summoner/application.html.erb +139 -0
- data/app/views/summoner/features/edit.html.erb +38 -0
- data/app/views/summoner/features/index.html.erb +55 -0
- data/app/views/summoner/features/show.html.erb +120 -0
- data/app/views/summoner/overrides/edit.html.erb +37 -0
- data/config/routes.rb +7 -0
- data/lib/generators/summoner/install/install_generator.rb +53 -0
- data/lib/generators/summoner/install/templates/create_summoner_tables.rb.erb +28 -0
- data/lib/generators/summoner/install/templates/features.yml +26 -0
- data/lib/generators/summoner/install/templates/summoner.rb +10 -0
- data/lib/summoner/configuration.rb +20 -0
- data/lib/summoner/engine.rb +9 -0
- data/lib/summoner/entity.rb +49 -0
- data/lib/summoner/fetcher.rb +66 -0
- data/lib/summoner/loader.rb +0 -0
- data/lib/summoner/sync.rb +102 -0
- data/lib/summoner/version.rb +3 -0
- data/lib/summoner.rb +11 -0
- data/lib/tasks/summoner_tasks.rake +20 -0
- 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,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,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
|
data/lib/summoner.rb
ADDED
|
@@ -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
|