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.
- checksums.yaml +4 -4
- data/README.md +884 -152
- data/app/controllers/reactive_actions/reactive_actions_controller.rb +5 -1
- data/lib/generators/reactive_actions/install/install_generator.rb +122 -52
- data/lib/generators/reactive_actions/install/templates/initializer.rb +46 -1
- data/lib/reactive_actions/concerns/rate_limiter.rb +174 -0
- data/lib/reactive_actions/concerns/security_checks.rb +108 -0
- data/lib/reactive_actions/configuration.rb +23 -3
- data/lib/reactive_actions/controller/rate_limiter.rb +187 -0
- data/lib/reactive_actions/errors.rb +17 -0
- data/lib/reactive_actions/rate_limiter.rb +165 -0
- data/lib/reactive_actions/reactive_action.rb +5 -0
- data/lib/reactive_actions/version.rb +1 -1
- data/lib/reactive_actions.rb +5 -1
- metadata +6 -2
@@ -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
|
data/lib/reactive_actions.rb
CHANGED
@@ -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.
|
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-
|
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
|