toggly 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e803470a2b9ba974d853c9b93a9da9404467f701c76ed6fbb0bd678627da7112
4
- data.tar.gz: 86c792820f5ddecf1c951118bafa9d8df632299e919d8c48165e2c80e13b4b9b
3
+ metadata.gz: 750edb3f8314278562443c8e2d1c55342f0cf412660c67be6518b73adb7e391c
4
+ data.tar.gz: 37e1959bfe363ca29d389c511663f170bac4d8501f3ae5343dcde76a43ced889
5
5
  SHA512:
6
- metadata.gz: fae2c7e8f07c7e88050e459ebfe2762895ae4fcebb73aced9f03985a5a12c5f01e73cecfd1943352610ac08a50eb8e8d57553203f59c99a7fcf781a80fe01a92
7
- data.tar.gz: 7bbe9e747d3aa3dcde62701bb26ccd3541184b68274e8da59d49fed6d515db3c6e3e9e63e964e28bed254c35d7e0ddefa130fc865a8886843a56e4b65b5b53c9
6
+ metadata.gz: 0ffa45e64fd2517b201de7701c74f0e797bdfbeddc76affc469fe37113e352b99a6024db42c36e9ce13d163f3530e6cc23a17bf770eeedd9f2c22dc3216c30b5
7
+ data.tar.gz: 4cf489e30d206a5bf9bd92153b865e57ebaf04190909c16559d10e90edfd1a017ef27bed38c6356186102167cf0617111f0f34a950443fb3ce32b50c69d3cb9a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-08-21
9
+
10
+ ### Added
11
+
12
+ - ContextProperty entity filters (`context_kind` / `context_requirement_type`) with operators eq, neq, gt, gte, lt, lte, in, contains. Fail closed. User filters AND entity filters; percentage stays user-only.
13
+ - `Toggly::EntityContext`, `Toggly.register_context`, and optional startup PUT `sdk/{appKey}/contexts` (`disable_entity_context_registration` to opt out).
14
+
8
15
  ## [0.1.0] - 2024-XX-XX
9
16
 
10
17
  ### Added
data/README.md CHANGED
@@ -4,6 +4,8 @@ Core Ruby SDK for [Toggly](https://toggly.io) feature flag management.
4
4
 
5
5
  **Zero dependencies** - pure Ruby implementation.
6
6
 
7
+ Entity `ContextProperty` filters evaluate `Toggly::EntityContext` (`kind`, `key`, `attributes`) and are ANDed with user filters. `Toggly.register_context` optionally PUTs schemas to `sdk/{appKey}/contexts`.
8
+
7
9
  ## Installation
8
10
 
9
11
  ```ruby
data/lib/toggly/client.rb CHANGED
@@ -52,6 +52,7 @@ module Toggly
52
52
  @refresh_thread = nil
53
53
 
54
54
  initialize_definitions
55
+ Toggly.register_entity_contexts_at_startup(@config) unless @config.disable_entity_context_registration
55
56
  start_background_refresh unless @config.disable_background_refresh
56
57
  end
57
58
 
data/lib/toggly/config.rb CHANGED
@@ -35,6 +35,7 @@ module Toggly
35
35
 
36
36
  # @return [Boolean] Enable WebSocket live updates (default: true)
37
37
  attr_accessor :enable_live_updates
38
+ attr_accessor :disable_entity_context_registration
38
39
 
39
40
  # @return [String] Application version
40
41
  attr_accessor :app_version
@@ -73,6 +74,7 @@ module Toggly
73
74
  @enable_undefined_in_dev = options[:enable_undefined_in_dev] || false
74
75
  @disable_background_refresh = options[:disable_background_refresh] || false
75
76
  @enable_live_updates = options.fetch(:enable_live_updates, true)
77
+ @disable_entity_context_registration = options.fetch(:disable_entity_context_registration, false)
76
78
  @app_version = options[:app_version]
77
79
  @instance_name = options[:instance_name]
78
80
  @defaults = options[:defaults] || {}
@@ -11,18 +11,14 @@ module Toggly
11
11
  # )
12
12
  class Context
13
13
  # @return [String, nil] User identity for percentage rollouts and targeting
14
- attr_reader :identity
14
+ # identity, groups, traits, and optional entity for ContextProperty filters
15
+ attr_reader :identity, :groups, :traits, :entity
15
16
 
16
- # @return [Array<String>] Groups the user belongs to
17
- attr_reader :groups
18
-
19
- # @return [Hash<String, Object>] Custom traits for contextual targeting
20
- attr_reader :traits
21
-
22
- def initialize(identity: nil, groups: [], traits: {})
17
+ def initialize(identity: nil, groups: [], traits: {}, entity: nil)
23
18
  @identity = identity&.to_s
24
19
  @groups = Array(groups).map(&:to_s)
25
20
  @traits = normalize_traits(traits)
21
+ @entity = entity
26
22
  end
27
23
 
28
24
  # Create a context with just an identity
@@ -80,7 +76,8 @@ module Toggly
80
76
  Context.new(
81
77
  identity: @identity,
82
78
  groups: @groups,
83
- traits: @traits.merge(normalize_traits(new_traits))
79
+ traits: @traits.merge(normalize_traits(new_traits)),
80
+ entity: @entity
84
81
  )
85
82
  end
86
83
 
@@ -92,7 +89,8 @@ module Toggly
92
89
  Context.new(
93
90
  identity: @identity,
94
91
  groups: @groups + new_groups.flatten.map(&:to_s),
95
- traits: @traits
92
+ traits: @traits,
93
+ entity: @entity
96
94
  )
97
95
  end
98
96
 
@@ -217,7 +217,12 @@ module Toggly
217
217
  base = @config.definitions_url || @config.base_url
218
218
  ws_url = base.gsub(%r{^https://}, "wss://").gsub(%r{^http://}, "ws://")
219
219
  ws_url = ws_url.chomp("/")
220
- "#{ws_url}/#{@config.app_key}/ws"
220
+ params = {
221
+ "sdk" => "ruby",
222
+ "sdkVersion" => Toggly::VERSION
223
+ }
224
+ params["rev"] = @etag if @etag && !@etag.empty?
225
+ "#{ws_url}/#{@config.app_key}/ws?#{URI.encode_www_form(params)}"
221
226
  end
222
227
 
223
228
  def connect_websocket
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ # Entity context registry, mappers, and startup catalog PUT.
8
+ module Toggly
9
+ EntityContext = Struct.new(:kind, :key, :attributes, keyword_init: true) do
10
+ def attribute(name)
11
+ return nil unless attributes.is_a?(Hash)
12
+
13
+ return attributes[name] if attributes.key?(name)
14
+ return attributes[name.to_s] if attributes.key?(name.to_s)
15
+
16
+ attributes.each { |k, v| return v if k.to_s.casecmp?(name.to_s) }
17
+ nil
18
+ end
19
+
20
+ def attribute?(name)
21
+ return false unless attributes.is_a?(Hash)
22
+
23
+ attributes.key?(name) || attributes.key?(name.to_s) ||
24
+ attributes.keys.any? { |k| k.to_s.casecmp?(name.to_s) }
25
+ end
26
+ end
27
+
28
+ EntityContextPropertySchema = Struct.new(:name, :type, keyword_init: true)
29
+ EntityContextSchemaRegistration = Struct.new(:kind, :key_property, :display_name, :properties, keyword_init: true)
30
+
31
+ @entity_mappers = {}
32
+ @entity_schemas = {}
33
+ @entity_mutex = Mutex.new
34
+
35
+ class << self
36
+ def register_context(kind, schema: nil, &mapper)
37
+ @entity_mutex.synchronize do
38
+ @entity_mappers[kind] = mapper if mapper
39
+ if schema
40
+ schema.kind = kind
41
+ schema.display_name ||= kind
42
+ @entity_schemas[kind] = schema
43
+ end
44
+ end
45
+ end
46
+
47
+ def map_entity(kind, entity)
48
+ mapper = @entity_mutex.synchronize { @entity_mappers[kind] }
49
+ return nil unless mapper
50
+
51
+ mapper.call(entity)
52
+ end
53
+
54
+ def entity_schemas
55
+ @entity_mutex.synchronize { @entity_schemas.values.dup }
56
+ end
57
+
58
+ def clear_entity_context_registrations
59
+ @entity_mutex.synchronize do
60
+ @entity_mappers.clear
61
+ @entity_schemas.clear
62
+ end
63
+ end
64
+
65
+ def register_entity_contexts_at_startup(config)
66
+ return if config.respond_to?(:disable_entity_context_registration) && config.disable_entity_context_registration
67
+ return if config.app_key.nil? || config.app_key.empty?
68
+
69
+ regs = entity_schemas
70
+ return if regs.empty?
71
+
72
+ payload = {
73
+ contexts: regs.map do |r|
74
+ {
75
+ kind: r.kind,
76
+ keyProperty: r.key_property,
77
+ displayName: r.display_name || r.kind,
78
+ properties: Array(r.properties).map { |p| { name: p.name, type: p.type } }
79
+ }
80
+ end
81
+ }
82
+ uri = URI.join(config.base_url.end_with?("/") ? config.base_url : "#{config.base_url}/", "sdk/#{config.app_key}/contexts")
83
+ http = Net::HTTP.new(uri.host, uri.port)
84
+ http.use_ssl = uri.scheme == "https"
85
+ http.open_timeout = config.http_timeout || 10
86
+ http.read_timeout = config.http_timeout || 10
87
+ req = Net::HTTP::Put.new(uri)
88
+ req["Content-Type"] = "application/json"
89
+ req.body = JSON.generate(payload)
90
+ http.request(req)
91
+ rescue StandardError
92
+ nil
93
+ end
94
+ end
95
+ end
@@ -69,8 +69,53 @@ module Toggly
69
69
  private
70
70
 
71
71
  def evaluate_rules(definition, context, result = nil)
72
- definition.rules.each_with_index do |rule, index|
73
- rule_type = rule["type"] || rule[:type] || "always_on"
72
+ entity_rules, user_rules = definition.rules.partition { |rule| Evaluators::ContextProperty.context_property?(rule) }
73
+
74
+ if entity_rules.any?
75
+ entity_ok = evaluate_entity_group(definition, entity_rules, context)
76
+ unless entity_ok
77
+ result&.reason = "entity_filters_failed"
78
+ return false
79
+ end
80
+ return true if user_rules.empty?
81
+
82
+ return evaluate_filter_group(user_rules, definition.requirement_type, context, definition.feature_key, result)
83
+ end
84
+
85
+ sequential_rules(user_rules.empty? ? definition.rules : user_rules, definition, context, result)
86
+ end
87
+
88
+ def evaluate_entity_group(definition, rules, context)
89
+ entity = context&.entity
90
+ return false unless entity
91
+
92
+ results = rules.map { |rule| Evaluators::ContextProperty.evaluate_single(rule, entity) }
93
+ req = (definition.context_requirement_type || definition.requirement_type || "Any").to_s
94
+ req.casecmp?("All") ? results.all? : results.any?
95
+ end
96
+
97
+ def evaluate_filter_group(rules, requirement_type, context, feature_key, result)
98
+ req = (requirement_type || "Any").to_s
99
+ evaluations = rules.map do |rule|
100
+ rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "always_on"
101
+ evaluator = @registry.get(rule_type)
102
+ next false unless evaluator
103
+
104
+ begin
105
+ value = evaluator.evaluate(rule, context, feature_key: feature_key)
106
+ value == true
107
+ rescue StandardError
108
+ false
109
+ end
110
+ end
111
+ passed = req.casecmp?("All") ? evaluations.all? : evaluations.any?
112
+ result&.reason = passed ? "rule_matched" : "no_match" if result
113
+ passed
114
+ end
115
+
116
+ def sequential_rules(rules, definition, context, result)
117
+ rules.each_with_index do |rule, index|
118
+ rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "always_on"
74
119
  evaluator = @registry.get(rule_type)
75
120
 
76
121
  unless evaluator
@@ -81,7 +126,6 @@ module Toggly
81
126
  begin
82
127
  evaluation = evaluator.evaluate(rule, context, feature_key: definition.feature_key)
83
128
 
84
- # nil means continue to next rule
85
129
  next if evaluation.nil?
86
130
 
87
131
  if result
@@ -97,7 +141,6 @@ module Toggly
97
141
  end
98
142
  end
99
143
 
100
- # No rules matched, default to enabled (since the feature itself is enabled)
101
144
  result&.reason = "default_enabled"
102
145
  true
103
146
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for ContextProperty entity filters. Fail closed.
6
+ class ContextProperty < Base
7
+ def self.type
8
+ "contextproperty"
9
+ end
10
+
11
+ def evaluate(rule, context, feature_key: nil)
12
+ entity = context&.entity
13
+ return false unless entity
14
+
15
+ self.class.evaluate_single(rule, entity)
16
+ end
17
+
18
+ def self.context_property?(rule)
19
+ type = rule["type"] || rule[:type] || rule["name"] || rule[:name]
20
+ type.to_s.downcase == "contextproperty"
21
+ end
22
+
23
+ def self.evaluate_single(rule, entity)
24
+ property = rule_lookup(rule, "Property")
25
+ operator = rule_lookup(rule, "Operator")
26
+ expected = rule_lookup(rule, "Value")
27
+ value_type = (rule_lookup(rule, "ValueType") || "string").to_s.downcase
28
+ return false if property.to_s.strip.empty? || operator.to_s.strip.empty? || expected.nil?
29
+
30
+ operator = operator.to_s.downcase
31
+ return false unless entity.attribute?(property)
32
+
33
+ actual = entity.attribute(property)
34
+ compare(actual, operator, expected.to_s, value_type)
35
+ end
36
+
37
+ def self.rule_lookup(rule, key)
38
+ params = rule["parameters"] || rule[:parameters] || rule
39
+ params[key] || params[key.to_s] || params[key.to_sym] ||
40
+ params.find { |k, _| k.to_s.casecmp?(key.to_s) }&.last
41
+ end
42
+
43
+ def self.compare(actual, operator, expected, value_type)
44
+ case operator
45
+ when "eq"
46
+ actual.to_s.casecmp?(expected)
47
+ when "neq"
48
+ !actual.to_s.casecmp?(expected)
49
+ when "gt", "gte", "lt", "lte"
50
+ compare_ordered(actual, expected, value_type, operator)
51
+ when "in"
52
+ expected.split(",").map(&:strip).reject(&:empty?).any? { |c| c.casecmp?(actual.to_s) }
53
+ when "contains"
54
+ if value_type == "string[]"
55
+ Array(actual).any? { |v| v.to_s.casecmp?(expected) }
56
+ else
57
+ actual.to_s.downcase.include?(expected.downcase)
58
+ end
59
+ else
60
+ false
61
+ end
62
+ end
63
+
64
+ def self.compare_ordered(actual, expected, value_type, operator)
65
+ if value_type == "datetime"
66
+ a = Time.parse(actual.to_s)
67
+ e = Time.parse(expected.to_s)
68
+ cmp = a <=> e
69
+ elsif value_type == "number"
70
+ a = parse_number(actual)
71
+ e = parse_number(expected)
72
+ return false if a.nil? || e.nil?
73
+
74
+ cmp = a <=> e
75
+ else
76
+ return false
77
+ end
78
+ case operator
79
+ when "gt" then cmp.positive?
80
+ when "gte" then cmp >= 0
81
+ when "lt" then cmp.negative?
82
+ when "lte" then cmp <= 0
83
+ else false
84
+ end
85
+ rescue ArgumentError, TypeError
86
+ false
87
+ end
88
+
89
+ # Strict numeric parse. Invalid strings must not coerce to 0.0.
90
+ def self.parse_number(value)
91
+ n = Float(value)
92
+ return nil unless n.finite?
93
+
94
+ n
95
+ rescue ArgumentError, TypeError
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -30,7 +30,7 @@ module Toggly
30
30
  attr_reader :created_at
31
31
 
32
32
  # @return [Time, nil] When the feature was last updated
33
- attr_reader :updated_at
33
+ attr_reader :updated_at, :requirement_type, :context_kind, :context_requirement_type
34
34
 
35
35
  # @return [String, nil] Feature description
36
36
  attr_reader :description
@@ -38,6 +38,7 @@ module Toggly
38
38
  # Feature types
39
39
  TYPES = %w[Release Experiment Ops Permission].freeze
40
40
 
41
+ # rubocop:disable Metrics/ParameterLists
41
42
  def initialize(
42
43
  feature_key:,
43
44
  feature_type: "Release",
@@ -46,7 +47,10 @@ module Toggly
46
47
  metadata: {},
47
48
  description: nil,
48
49
  created_at: nil,
49
- updated_at: nil
50
+ updated_at: nil,
51
+ requirement_type: "Any",
52
+ context_kind: nil,
53
+ context_requirement_type: nil
50
54
  )
51
55
  @feature_key = feature_key.to_s
52
56
  @feature_type = validate_type(feature_type)
@@ -56,7 +60,11 @@ module Toggly
56
60
  @description = description
57
61
  @created_at = parse_time(created_at)
58
62
  @updated_at = parse_time(updated_at)
63
+ @requirement_type = requirement_type || "Any"
64
+ @context_kind = context_kind
65
+ @context_requirement_type = context_requirement_type
59
66
  end
67
+ # rubocop:enable Metrics/ParameterLists
60
68
 
61
69
  # Create from a hash (e.g., from JSON)
62
70
  #
@@ -84,7 +92,10 @@ module Toggly
84
92
  metadata: hash[:metadata] || {},
85
93
  description: hash[:description],
86
94
  created_at: hash[:createdAt] || hash[:created_at],
87
- updated_at: hash[:updatedAt] || hash[:updated_at]
95
+ updated_at: hash[:updatedAt] || hash[:updated_at],
96
+ requirement_type: hash[:requirementType] || hash[:requirement_type] || "Any",
97
+ context_kind: hash[:contextKind] || hash[:context_kind],
98
+ context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type]
88
99
  )
89
100
  end
90
101
 
@@ -81,6 +81,7 @@ module Toggly
81
81
  register(Evaluators::Targeting.new)
82
82
  register(Evaluators::TimeWindow.new)
83
83
  register(Evaluators::ContextualTargeting.new)
84
+ register(Evaluators::ContextProperty.new)
84
85
  end
85
86
  end
86
87
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/toggly.rb CHANGED
@@ -12,6 +12,8 @@ require_relative "toggly/evaluators/percentage"
12
12
  require_relative "toggly/evaluators/targeting"
13
13
  require_relative "toggly/evaluators/time_window"
14
14
  require_relative "toggly/evaluators/contextual_targeting"
15
+ require_relative "toggly/evaluators/context_property"
16
+ require_relative "toggly/entity_context"
15
17
  require_relative "toggly/registry"
16
18
  require_relative "toggly/evaluation_engine"
17
19
  require_relative "toggly/snapshot_providers/base"
@@ -89,6 +91,7 @@ module Toggly
89
91
  def reset!
90
92
  @client&.close
91
93
  @client = nil
94
+ clear_entity_context_registrations if respond_to?(:clear_entity_context_registrations)
92
95
  end
93
96
  end
94
97
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toggly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ops.ai
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-03 00:00:00.000000000 Z
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: High-performance Ruby SDK for Toggly feature flag management. Works with
14
14
  or without Toggly.io.
@@ -26,11 +26,13 @@ files:
26
26
  - lib/toggly/config.rb
27
27
  - lib/toggly/context.rb
28
28
  - lib/toggly/definitions_provider.rb
29
+ - lib/toggly/entity_context.rb
29
30
  - lib/toggly/errors.rb
30
31
  - lib/toggly/evaluation_engine.rb
31
32
  - lib/toggly/evaluators/always_off.rb
32
33
  - lib/toggly/evaluators/always_on.rb
33
34
  - lib/toggly/evaluators/base.rb
35
+ - lib/toggly/evaluators/context_property.rb
34
36
  - lib/toggly/evaluators/contextual_targeting.rb
35
37
  - lib/toggly/evaluators/percentage.rb
36
38
  - lib/toggly/evaluators/targeting.rb