reactive-actions 0.1.0.pre.alpha.2 → 0.1.0.pre.alpha.3

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.
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveActions
4
+ module Controller
5
+ # Controller module that adds rate limiting functionality
6
+ # Include this module in any controller that needs rate limiting
7
+ module RateLimiter
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ # Add rate limiting before_action ONLY if enabled
12
+ before_action :check_global_rate_limit, if: :should_check_global_rate_limit?
13
+ end
14
+
15
+ private
16
+
17
+ # Determine if global rate limiting should be checked
18
+ def should_check_global_rate_limit?
19
+ ReactiveActions.configuration.global_rate_limiting_active?
20
+ end
21
+
22
+ # Main rate limiting check method
23
+ def check_global_rate_limit
24
+ # Double-check that rate limiting is enabled (safety measure)
25
+ return unless ReactiveActions.configuration.rate_limiting_enabled
26
+
27
+ rate_limit_info = ReactiveActions::RateLimiter.check!(
28
+ key: global_rate_limit_key,
29
+ limit: ReactiveActions.configuration.global_rate_limit,
30
+ window: ReactiveActions.configuration.global_rate_limit_window
31
+ )
32
+
33
+ # Add rate limit headers to response
34
+ add_rate_limit_headers(rate_limit_info)
35
+ rescue ReactiveActions::RateLimitExceededError => e
36
+ add_rate_limit_headers_for_exceeded(e)
37
+ handle_rate_limit_exceeded_error(e)
38
+ end
39
+
40
+ # Generate the cache key for global rate limiting
41
+ def global_rate_limit_key
42
+ # Use the configured key generator or default logic
43
+ if ReactiveActions.configuration.rate_limit_key_generator
44
+ action_name = respond_to?(:extract_action_name, true) ? extract_action_name : 'unknown'
45
+ ReactiveActions.configuration.rate_limit_key_generator.call(request, action_name)
46
+ else
47
+ default_global_rate_limit_key
48
+ end
49
+ end
50
+
51
+ # Default key generation strategy
52
+ def default_global_rate_limit_key
53
+ # Prefer user-based limiting if available, fallback to IP
54
+ if respond_to?(:current_user, true) && current_user
55
+ "global:user:#{current_user.id}"
56
+ elsif request.respond_to?(:remote_ip)
57
+ "global:ip:#{request.remote_ip}"
58
+ else
59
+ "global:unknown:#{SecureRandom.hex(8)}"
60
+ end
61
+ end
62
+
63
+ # Add rate limit headers to successful responses
64
+ def add_rate_limit_headers(rate_limit_info)
65
+ rate_limit_basic_headers(rate_limit_info)
66
+ rate_limit_reset_header(rate_limit_info)
67
+ end
68
+
69
+ # Add rate limit headers for exceeded limits
70
+ def add_rate_limit_headers_for_exceeded(error)
71
+ rate_limit_exceeded_headers(error)
72
+ rate_limit_retry_after_header(error)
73
+ end
74
+
75
+ # Set basic rate limit headers
76
+ def rate_limit_basic_headers(rate_limit_info)
77
+ response.headers['X-RateLimit-Limit'] = rate_limit_info[:limit].to_s
78
+ response.headers['X-RateLimit-Remaining'] = rate_limit_info[:remaining].to_s
79
+ response.headers['X-RateLimit-Window'] = rate_limit_info[:window].to_i.to_s
80
+ end
81
+
82
+ # Set rate limit reset header based on window
83
+ def rate_limit_reset_header(rate_limit_info)
84
+ window_seconds = rate_limit_info[:window].to_i
85
+ current_window_start = (Time.current.to_i / window_seconds) * window_seconds
86
+ reset_time = current_window_start + window_seconds
87
+ response.headers['X-RateLimit-Reset'] = reset_time.to_s
88
+ end
89
+
90
+ # Set headers for exceeded rate limits
91
+ def rate_limit_exceeded_headers(error)
92
+ response.headers['X-RateLimit-Limit'] = error.limit.to_s
93
+ response.headers['X-RateLimit-Remaining'] = '0'
94
+ response.headers['X-RateLimit-Window'] = error.window.to_i.to_s
95
+ response.headers['X-RateLimit-Reset'] = (Time.current + error.retry_after).to_i.to_s
96
+ end
97
+
98
+ # Set retry after header
99
+ def rate_limit_retry_after_header(error)
100
+ response.headers['Retry-After'] = error.retry_after.to_s
101
+ end
102
+
103
+ # Handle rate limit exceeded errors
104
+ # Override this method in your controller if you need custom handling
105
+ def handle_rate_limit_exceeded_error(error)
106
+ # Check if the including controller has its own error handling
107
+ if respond_to?(:handle_reactive_actions_error, true)
108
+ handle_reactive_actions_error(error)
109
+ else
110
+ # Default rate limit error response - only render if not already rendered
111
+ render_rate_limit_error(error) unless performed?
112
+ end
113
+ end
114
+
115
+ # Default rate limit error rendering
116
+ def render_rate_limit_error(error)
117
+ render json: {
118
+ success: false,
119
+ error: {
120
+ type: 'RateLimitExceededError',
121
+ message: error.message,
122
+ code: 'RATE_LIMIT_EXCEEDED',
123
+ limit: error.limit,
124
+ window: error.window.to_i,
125
+ retry_after: error.retry_after
126
+ }
127
+ }, status: :too_many_requests
128
+ end
129
+
130
+ # Get current rate limit status without consuming a request
131
+ def rate_limit_status
132
+ return nil unless ReactiveActions.configuration.rate_limiting_enabled
133
+
134
+ ReactiveActions::RateLimiter.status(
135
+ key: global_rate_limit_key,
136
+ limit: ReactiveActions.configuration.global_rate_limit,
137
+ window: ReactiveActions.configuration.global_rate_limit_window
138
+ )
139
+ end
140
+
141
+ # Reset rate limit for current key (useful for testing or admin overrides)
142
+ def reset_rate_limit!
143
+ return unless ReactiveActions.configuration.rate_limiting_enabled
144
+
145
+ ReactiveActions::RateLimiter.reset!(
146
+ key: global_rate_limit_key,
147
+ window: ReactiveActions.configuration.global_rate_limit_window
148
+ )
149
+ end
150
+
151
+ # Helper method to log rate limiting events
152
+ def log_rate_limit_event(event_type, details = {})
153
+ ReactiveActions.logger.info(
154
+ "Rate Limit #{event_type.to_s.capitalize}: #{global_rate_limit_key} - #{details}"
155
+ )
156
+ end
157
+
158
+ # Class methods for the RateLimiter module
159
+ module ClassMethods
160
+ # Configure rate limiting for specific actions
161
+ # Example: rate_limit_action :show, limit: 100, window: 1.minute
162
+ def rate_limit_action(action_name, limit:, window:, **options)
163
+ before_action(**options) do
164
+ next unless ReactiveActions.configuration.rate_limiting_enabled
165
+
166
+ rate_limit_info = ReactiveActions::RateLimiter.check!(
167
+ key: "action:#{action_name}:#{global_rate_limit_key}",
168
+ limit: limit,
169
+ window: window
170
+ )
171
+
172
+ add_rate_limit_headers(rate_limit_info)
173
+ rescue ReactiveActions::RateLimitExceededError => e
174
+ add_rate_limit_headers_for_exceeded(e)
175
+ handle_rate_limit_exceeded_error(e)
176
+ end
177
+ end
178
+
179
+ # Skip rate limiting for specific actions
180
+ # Example: skip_rate_limiting :health_check, :status
181
+ def skip_rate_limiting(*action_names)
182
+ skip_before_action :check_global_rate_limit, only: action_names
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -21,4 +21,21 @@ module ReactiveActions
21
21
 
22
22
  # Raised when the user is not authorized to perform the action
23
23
  class UnauthorizedError < Error; end
24
+
25
+ # Raised when a security check fails
26
+ class SecurityCheckError < Error; end
27
+
28
+ # Raised when rate limit is exceeded
29
+ class RateLimitExceededError < Error
30
+ attr_reader :limit, :window, :retry_after, :current
31
+
32
+ def initialize(limit:, window:, retry_after:, current:, message: nil)
33
+ @limit = limit
34
+ @window = window
35
+ @retry_after = retry_after
36
+ @current = current
37
+
38
+ super(message || "Rate limit exceeded: #{current}/#{limit} requests in #{window.inspect}")
39
+ end
40
+ end
24
41
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveActions
4
+ # Core rate limiting class that handles Rails cache operations
5
+ # Provides atomic operations for checking, incrementing, and managing rate limits
6
+ class RateLimiter
7
+ class << self
8
+ # Check if the request is within rate limits
9
+ # @param key [String] The cache key to use for rate limiting
10
+ # @param limit [Integer] Maximum number of requests allowed
11
+ # @param window [ActiveSupport::Duration] Time window for the limit
12
+ # @param cost [Integer] Cost of this request (default: 1)
13
+ # @return [Hash] Rate limit information
14
+ # @raise [RateLimitExceededError] When rate limit is exceeded
15
+ def check!(key:, limit:, window:, cost: 1)
16
+ cache_key = build_cache_key(key, window)
17
+ current_count = get_current_count(cache_key)
18
+
19
+ # Check if this request would exceed the limit
20
+ raise_rate_limit_error(current_count, limit, window, cache_key) if current_count + cost > limit
21
+
22
+ # Only increment if cost > 0
23
+ new_count = if cost.positive?
24
+ increment_cache_counter(cache_key, cost, window)
25
+ else
26
+ current_count
27
+ end
28
+
29
+ build_result(new_count, limit, window, cache_key)
30
+ end
31
+
32
+ # Check rate limit without raising an error
33
+ # @param key [String] The cache key to use for rate limiting
34
+ # @param limit [Integer] Maximum number of requests allowed
35
+ # @param window [ActiveSupport::Duration] Time window for the limit
36
+ # @return [Hash] Rate limit information with :exceeded boolean
37
+ def check(key:, limit:, window:)
38
+ cache_key = build_cache_key(key, window)
39
+ current_count = get_current_count(cache_key)
40
+
41
+ if current_count >= limit
42
+ ttl = get_ttl(cache_key, window)
43
+ {
44
+ limit: limit,
45
+ remaining: 0,
46
+ current: current_count,
47
+ window: window,
48
+ exceeded: true,
49
+ retry_after: ttl,
50
+ key: cache_key
51
+ }
52
+ else
53
+ {
54
+ limit: limit,
55
+ remaining: limit - current_count,
56
+ current: current_count,
57
+ window: window,
58
+ exceeded: false,
59
+ key: cache_key
60
+ }
61
+ end
62
+ end
63
+
64
+ # Reset rate limit for a key
65
+ # @param key [String] The cache key to reset
66
+ # @param window [ActiveSupport::Duration] Time window for the limit
67
+ def reset!(key:, window:)
68
+ cache_key = build_cache_key(key, window)
69
+ cache_store.delete(cache_key)
70
+ cache_store.delete("#{cache_key}:ttl")
71
+ end
72
+
73
+ # Get current rate limit status
74
+ # @param key [String] The cache key to check
75
+ # @param limit [Integer] Maximum number of requests allowed
76
+ # @param window [ActiveSupport::Duration] Time window for the limit
77
+ # @return [Hash] Current rate limit status
78
+ def status(key:, limit:, window:)
79
+ cache_key = build_cache_key(key, window)
80
+ current = get_current_count(cache_key)
81
+ ttl = get_ttl(cache_key, window)
82
+
83
+ {
84
+ limit: limit,
85
+ remaining: [limit - current, 0].max,
86
+ current: current,
87
+ window: window,
88
+ reset_at: Time.current + ttl.seconds
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ # Get the appropriate cache store
95
+ # In test environment with NullStore, use MemoryStore instead
96
+ def cache_store
97
+ @cache_store ||= if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
98
+ ActiveSupport::Cache::MemoryStore.new
99
+ else
100
+ Rails.cache
101
+ end
102
+ end
103
+
104
+ # Get current count from cache
105
+ def get_current_count(cache_key)
106
+ cache_store.read(cache_key) || 0
107
+ end
108
+
109
+ # Get TTL for cache key
110
+ def get_ttl(cache_key, window)
111
+ cache_store.read("#{cache_key}:ttl") || window.to_i
112
+ end
113
+
114
+ # Raise rate limit exceeded error
115
+ def raise_rate_limit_error(current_count, limit, window, cache_key)
116
+ remaining_ttl = get_ttl(cache_key, window)
117
+ raise RateLimitExceededError.new(
118
+ limit: limit,
119
+ window: window,
120
+ retry_after: remaining_ttl,
121
+ current: current_count
122
+ )
123
+ end
124
+
125
+ # Increment the counter in cache
126
+ def increment_cache_counter(cache_key, cost, window)
127
+ # Try atomic increment first (if supported)
128
+ new_count = cache_store.increment(cache_key, cost)
129
+
130
+ if new_count.nil?
131
+ # Key doesn't exist or increment not supported, use read-modify-write
132
+ current = cache_store.read(cache_key) || 0
133
+ new_count = current + cost
134
+ cache_store.write(cache_key, new_count, expires_in: window)
135
+
136
+ # Set TTL tracking
137
+ cache_store.write("#{cache_key}:ttl", window.to_i, expires_in: window) unless cache_store.exist?("#{cache_key}:ttl")
138
+ else
139
+ # Increment worked, ensure TTL is set
140
+ cache_store.write("#{cache_key}:ttl", window.to_i, expires_in: window) unless cache_store.exist?("#{cache_key}:ttl")
141
+ end
142
+
143
+ new_count
144
+ end
145
+
146
+ # Build result hash
147
+ def build_result(count, limit, window, cache_key)
148
+ {
149
+ limit: limit,
150
+ remaining: [limit - count, 0].max,
151
+ current: count,
152
+ window: window,
153
+ key: cache_key
154
+ }
155
+ end
156
+
157
+ # Build a cache key that includes the time window
158
+ # This ensures different windows don't interfere with each other
159
+ def build_cache_key(key, window)
160
+ window_start = (Time.current.to_i / window.to_i) * window.to_i
161
+ "reactive_actions:rate_limit:#{key}:#{window_start}:#{window.to_i}"
162
+ end
163
+ end
164
+ end
165
+ end
@@ -4,6 +4,9 @@ module ReactiveActions
4
4
  # Base class for reactive actions
5
5
  # Provides common functionality for executing actions and handling responses
6
6
  class ReactiveAction
7
+ include Concerns::SecurityChecks
8
+ include Concerns::RateLimiter
9
+
7
10
  attr_reader :action_params, :result, :controller
8
11
 
9
12
  # Initialize a new reactive action
@@ -25,6 +28,8 @@ module ReactiveActions
25
28
  ReactiveActions.logger.info "Running action #{self.class.name} with params: #{action_params.inspect}"
26
29
 
27
30
  begin
31
+ # Run security checks first
32
+ run_security_checks
28
33
  # Run the action
29
34
  controller.instance_exec(&method(:action))
30
35
  # Run the response
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactiveActions
4
- VERSION = '0.1.0-alpha.2'
4
+ VERSION = '0.1.0-alpha.3'
5
5
  end
@@ -3,8 +3,12 @@
3
3
  require 'reactive_actions/version'
4
4
  require 'reactive_actions/configuration'
5
5
  require 'reactive_actions/engine'
6
- require 'reactive_actions/reactive_action'
7
6
  require 'reactive_actions/errors'
7
+ require 'reactive_actions/rate_limiter'
8
+ require 'reactive_actions/controller/rate_limiter'
9
+ require 'reactive_actions/concerns/rate_limiter'
10
+ require 'reactive_actions/concerns/security_checks'
11
+ require 'reactive_actions/reactive_action'
8
12
 
9
13
  # Main namespace for the ReactiveActions gem
10
14
  # Provides functionality for creating and executing reactive actions in Rails applications
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reactive-actions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.2
4
+ version: 0.1.0.pre.alpha.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Istvan Meszaros
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-06-12 00:00:00.000000000 Z
11
+ date: 2025-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -52,9 +52,13 @@ files:
52
52
  - lib/generators/reactive_actions/install/templates/initializer.rb
53
53
  - lib/reactive-actions.rb
54
54
  - lib/reactive_actions.rb
55
+ - lib/reactive_actions/concerns/rate_limiter.rb
56
+ - lib/reactive_actions/concerns/security_checks.rb
55
57
  - lib/reactive_actions/configuration.rb
58
+ - lib/reactive_actions/controller/rate_limiter.rb
56
59
  - lib/reactive_actions/engine.rb
57
60
  - lib/reactive_actions/errors.rb
61
+ - lib/reactive_actions/rate_limiter.rb
58
62
  - lib/reactive_actions/reactive_action.rb
59
63
  - lib/reactive_actions/version.rb
60
64
  homepage: https://github.com/IstvanMs/reactive-actions