Skip to content
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "spec/engine-test-data"]
path = spec/engine-test-data
url = [email protected]:Flagsmith/engine-test-data.git
branch = v1.0.0
branch = main
31 changes: 31 additions & 0 deletions dev_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'bundler/setup'
require_relative 'lib/flagsmith'

flagsmith = Flagsmith::Client.new(
environment_key: ''
)

begin
flags = flagsmith.get_environment_flags

beta_users_flag = flags['beta_users']

if beta_users_flag
puts "Flag found!"
else
puts "error getting flag environment"
end

puts "-" * 50
puts "All flags"
flags.all_flags.each do |flag|
puts " - #{flag.feature_name}: enabled=#{flag.enabled?}, value=#{flag.value.inspect}"
end

rescue StandardError => e
puts "Error: #{e.message}"
puts e.backtrace.join("\n")
end
1 change: 1 addition & 0 deletions lib/flagsmith/engine/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_relative 'segments/evaluator'
require_relative 'segments/models'
require_relative 'utils/hash_func'
require_relative 'evaluationContext/mappers'

module Flagsmith
module Engine
Expand Down
21 changes: 21 additions & 0 deletions lib/flagsmith/engine/evaluation/core.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Flagsmith
module Engine
module Evaluation
module Core
# Get evaluation result from evaluation context
#
# @param evaluation_context [Hash] The evaluation context
# @return [Hash] Evaluation result with flags and segments
def self.get_evaluation_result(evaluation_context)
# TODO: Implement core evaluation logic
{
flags: {},
segments: []
}
end
end
end
end
end
233 changes: 233 additions & 0 deletions lib/flagsmith/engine/evaluation/mappers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# frozen_string_literal: true

module Flagsmith
module Engine
module EvaluationContext
module Mappers
# Using integer constant instead of -Float::INFINITY because the JSON serializer rejects infinity values
HIGHEST_PRIORITY = 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for safety, I'd prefer this to be lower than 0. Also, our naming convention for these constants is strongest-weakest:

Suggested change
HIGHEST_PRIORITY = 0
STRONGEST_PRIORITY = -99_999_999

WEAKEST_PRIORITY = 99_999_999

# @param environment [Flagsmith::Engine::Environment] The environment model
# @param identity [Flagsmith::Engine::Identity, nil] Optional identity model
# @param override_traits [Array<Flagsmith::Engine::Identities::Trait>, nil] Optional override traits
# @return [Hash] Evaluation context with environment, features, segments, and optionally identity
def self.get_evaluation_context(environment, identity = nil, override_traits = nil)
environment_context = map_environment_model_to_evaluation_context(environment)
identity_context = identity ? map_identity_model_to_identity_context(identity, override_traits) : nil

context = environment_context.dup
context[:identity] = identity_context if identity_context

context
end

# Maps environment model to evaluation context
#
# @param environment [Flagsmith::Engine::Environment] The environment model
# @return [Hash] Context with :environment, :features, and :segments keys
def self.map_environment_model_to_evaluation_context(environment)
environment_context = {
key: environment.api_key,
name: environment.project.name
}

# Map feature states to features hash
features = {}
environment.feature_states.each do |fs|
# Map multivariate values if present
variants = nil
if fs.multivariate_feature_state_values&.any?
variants = fs.multivariate_feature_state_values.map do |mv|
{
value: mv.multivariate_feature_option.value,
weight: mv.percentage_allocation,
priority: mv.id || uuid_to_big_int(mv.mv_fs_value_uuid)
}
end
end

feature_hash = {
key: fs.django_id&.to_s || fs.uuid,
feature_key: fs.feature.id.to_s,
name: fs.feature.name,
enabled: fs.enabled,
value: fs.get_value
}

feature_hash[:variants] = variants if variants
feature_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority
feature_hash[:metadata] = { flagsmithId: fs.feature.id }

features[fs.feature.name] = feature_hash
end

# Map segments from project
segments = {}
environment.project.segments.each do |segment|
overrides = segment.feature_states.map do |fs|
override_hash = {
key: fs.django_id&.to_s || fs.uuid,
feature_key: fs.feature.id.to_s,
name: fs.feature.name,
enabled: fs.enabled,
value: fs.get_value
}
override_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority
override_hash[:metadata] = { flagsmithId: fs.feature.id }
override_hash
end

segments[segment.id.to_s] = {
key: segment.id.to_s,
name: segment.name,
rules: segment.rules.map { |rule| map_segment_rule_model_to_rule(rule) },
overrides: overrides,
metadata: {
source: 'API',
flagsmith_id: segment.id
}
}
end

# Map identity overrides to segments
if environment.identity_overrides&.any?
identity_override_segments = map_identity_overrides_to_segments(environment.identity_overrides)
segments.merge!(identity_override_segments)
end

{
environment: environment_context,
features: features,
segments: segments
}
end

def self.uuid_to_big_int(uuid)
uuid.gsub('-', '').to_i(16)
end

# Maps identity model to identity context
#
# @param identity [Flagsmith::Engine::Identity] The identity model
# @param override_traits [Array<Flagsmith::Engine::Identities::Trait>, nil] Optional override traits
# @return [Hash] Identity context with :identifier, :key, and :traits
def self.map_identity_model_to_identity_context(identity, override_traits = nil)
# Use override traits if provided, otherwise use identity's traits
traits = override_traits || identity.identity_traits

# Map traits to a hash with trait key => trait value
traits_hash = {}
traits.each do |trait|
traits_hash[trait.trait_key] = trait.trait_value
end

{
identifier: identity.identifier,
key: identity.django_id&.to_s || identity.composite_key,
traits: traits_hash
}
end

# Maps segment rule model to rule hash
#
# @param rule [Flagsmith::Engine::Segments::Rule] The segment rule model
# @return [Hash] Mapped rule with :type, :conditions, and :rules
def self.map_segment_rule_model_to_rule(rule)
result = {
type: rule.type
}

# Map conditions if present
if rule.conditions&.any?
result[:conditions] = rule.conditions.map do |condition|
{
property: condition.property,
operator: condition.operator,
value: condition.value
}
end
else
result[:conditions] = []
end

if rule.rules&.any?
result[:rules] = rule.rules.map { |nested_rule| map_segment_rule_model_to_rule(nested_rule) }
else
result[:rules] = []
end

result
end

# Maps identity overrides to segments
#
# @param identity_overrides [Array<Flagsmith::Engine::Identity>] Array of identity override models
# @return [Hash] Segments hash for identity overrides
def self.map_identity_overrides_to_segments(identity_overrides)
require 'digest'

segments = {}
features_to_identifiers = {}

identity_overrides.each do |identity|
next if identity.identity_features.nil? || !identity.identity_features.any?

# Sort features by name for consistent hashing
sorted_features = identity.identity_features.to_a.sort_by { |fs| fs.feature.name }

# Create override keys for hashing
overrides_key = sorted_features.map do |fs|
{
feature_key: fs.feature.id.to_s,
name: fs.feature.name,
enabled: fs.enabled,
value: fs.get_value,
priority: WEAKEST_PRIORITY,
metadata: {
flagsmithId: fs.feature.id
}
}
end

# Create hash of the overrides to group identities with same overrides
overrides_hash = Digest::SHA1.hexdigest(overrides_key.to_json)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider one of the following alternatives.

  1. Using inspect instead of to_json might be more performant, and doesn't require us to avoid Inf:
Suggested change
overrides_hash = Digest::SHA1.hexdigest(overrides_key.to_json)
overrides_hash = Digest::SHA1.hexdigest(overrides_key.inspect)
  1. I'm not too familiar with Ruby's Object::hash, but it could be sufficient in our case. We could drop the digest dependency that way:
Suggested change
overrides_hash = Digest::SHA1.hexdigest(overrides_key.to_json)
overrides_hash = overrides_key.hash

Copy link
Contributor Author

@Zaimwa9 Zaimwa9 Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually reverting this change to inspect as I discovered that the hash is not consistent over application restarts

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not persisting this hash anywhere, so I don't think it's a requirement for it to be consistent across runtimes? Am I missing something?


features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key }
features_to_identifiers[overrides_hash][:identifiers] << identity.identifier
end

# Create segments for each unique set of overrides
features_to_identifiers.each do |overrides_hash, data|
segment_key = "identity_override_#{overrides_hash}"

segments[segment_key] = {
key: segment_key,
name: 'identity_override',
rules: [
{
type: 'ALL',
conditions: [
{
property: '$.identity.identifier',
operator: 'IN',
value: data[:identifiers].join(',')
}
],
rules: []
}
],
metadata: {
source: 'identity_override'
},
overrides: data[:overrides]
}
end

segments
end
end
end
end
end

2 changes: 1 addition & 1 deletion spec/engine-test-data
Submodule engine-test-data updated 190 files
69 changes: 35 additions & 34 deletions spec/engine/e2e/engine_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,44 @@

require 'spec_helper'

def load_test_cases(filepath)
data = JSON.parse(File.open(filepath).read, symbolize_names: true)
environment = Flagsmith::Engine::Environment.build(data[:environment])

data[:identities_and_responses].map do |test_case|
identity = Flagsmith::Engine::Identity.build(test_case[:identity])
{
environment: environment,
identity: identity,
response: test_case[:response]
}
end
def get_test_files
test_data_dir = File.join(APP_ROOT, 'spec/engine-test-data/test_cases')
Dir.glob(File.join(test_data_dir, '*.{json,jsonc}')).sort
end

def parse_jsonc(content)
# Simple JSONC parser: remove single-line comments
# JSON.parse will handle the rest
cleaned = content.lines.reject { |line| line.strip.start_with?('//') }.join
JSON.parse(cleaned, symbolize_names: true)
end

def load_test_file(filepath)
content = File.read(filepath)
parse_jsonc(content)
end

RSpec.describe Flagsmith::Engine do
load_test_cases(
File.join(APP_ROOT, 'spec/engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json')
).each do |test_case|
engine = Flagsmith::Engine::Engine.new
json_flags = test_case.dig(:response, :flags).sort_by { |json| json.dig(:feature, :name) }
feature_states = engine.get_identity_feature_states(test_case[:environment], test_case[:identity]).sort_by { |fs| fs.feature.name }

it { expect(feature_states.length).to eq(json_flags.length) }

json_flags.each.with_index do |json_flag, index|
describe "feature state with ID #{json_flag.dig(:feature, :id)}" do
subject { feature_states[index] }

context '#enabled?' do
it { expect(subject.enabled?).to eq(json_flag[:enabled]) }
end

context '#get_value' do
it {
expect(subject.get_value(test_case[:identity].django_id)).to eq(json_flag[:feature_state_value])
}
end
test_files = get_test_files

raise "No test files found" if test_files.empty?

test_files.each do |filepath|
test_name = File.basename(filepath, File.extname(filepath))

describe test_name do
it 'should produce the expected evaluation result' do
test_case = load_test_file(filepath)

test_evaluation_context = test_case[:context]
test_expected_result = test_case[:result]

# TODO: Implement evaluation logic
evaluation_result = {}


# TODO: Uncomment when evaluation is implemented
# expect(evaluation_result).to eq(test_expected_result)
end
end
end
Expand Down
Loading
Loading