reactive-actions 0.1.0.pre.alpha.1

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.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
6
+ load 'rails/tasks/engine.rake'
7
+
8
+ load 'rails/tasks/statistics.rake'
9
+
10
+ require 'bundler/gem_tasks'
11
+
12
+ require 'rspec/core/rake_task'
13
+ RSpec::Core::RakeTask.new(:spec)
14
+
15
+ task default: :spec
@@ -0,0 +1,100 @@
1
+ // ReactiveActions JavaScript Client for Rails 8
2
+ // ES Module compatible version
3
+
4
+ class ReactiveActionsClient {
5
+ constructor() {
6
+ this.baseUrl = '/reactive_actions/execute';
7
+ }
8
+
9
+ // Get CSRF token from meta tag
10
+ getCSRFToken() {
11
+ const metaTag = document.querySelector('meta[name="csrf-token"]');
12
+ return metaTag ? metaTag.getAttribute('content') : null;
13
+ }
14
+
15
+ // Execute an action with parameters
16
+ async execute(actionName, actionParams = {}, options = {}) {
17
+ const method = options.method || 'POST';
18
+ const contentType = options.contentType || 'application/json';
19
+
20
+ // Build request options
21
+ const requestOptions = {
22
+ method: method,
23
+ headers: {
24
+ 'Content-Type': contentType,
25
+ 'X-Requested-With': 'XMLHttpRequest'
26
+ },
27
+ credentials: 'same-origin'
28
+ };
29
+
30
+ // Add CSRF token if available
31
+ const csrfToken = this.getCSRFToken();
32
+ if (csrfToken) {
33
+ requestOptions.headers['X-CSRF-Token'] = csrfToken;
34
+ }
35
+
36
+ // Build URL or prepare body based on HTTP method
37
+ let url = this.baseUrl;
38
+ if (['GET', 'HEAD'].includes(method.toUpperCase())) {
39
+ // For GET requests, append parameters to URL
40
+ const params = new URLSearchParams({
41
+ action_name: actionName,
42
+ action_params: JSON.stringify(actionParams)
43
+ });
44
+ url = `${url}?${params.toString()}`;
45
+ } else {
46
+ // For other methods, send in request body
47
+ requestOptions.body = JSON.stringify({
48
+ action_name: actionName,
49
+ action_params: actionParams
50
+ });
51
+ }
52
+
53
+ try {
54
+ const response = await fetch(url, requestOptions);
55
+ const data = await response.json();
56
+
57
+ // Add response status and ok to the result
58
+ return {
59
+ ...data,
60
+ status: response.status,
61
+ ok: response.ok
62
+ };
63
+ } catch (error) {
64
+ console.error('ReactiveActions error:', error);
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ // Convenience methods for different HTTP verbs
70
+ async get(actionName, actionParams = {}, options = {}) {
71
+ return this.execute(actionName, actionParams, { ...options, method: 'GET' });
72
+ }
73
+
74
+ async post(actionName, actionParams = {}, options = {}) {
75
+ return this.execute(actionName, actionParams, { ...options, method: 'POST' });
76
+ }
77
+
78
+ async put(actionName, actionParams = {}, options = {}) {
79
+ return this.execute(actionName, actionParams, { ...options, method: 'PUT' });
80
+ }
81
+
82
+ async patch(actionName, actionParams = {}, options = {}) {
83
+ return this.execute(actionName, actionParams, { ...options, method: 'PATCH' });
84
+ }
85
+
86
+ async delete(actionName, actionParams = {}, options = {}) {
87
+ return this.execute(actionName, actionParams, { ...options, method: 'DELETE' });
88
+ }
89
+ }
90
+
91
+ // Create and export the instance
92
+ const ReactiveActions = new ReactiveActionsClient();
93
+
94
+ // For backward compatibility, also expose as global
95
+ if (typeof window !== 'undefined') {
96
+ window.ReactiveActions = ReactiveActions;
97
+ }
98
+
99
+ // ES Module export
100
+ export default ReactiveActions;
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveActions
4
+ # Base controller for the ReactiveActions engine
5
+ # Provides common functionality and settings for all controllers in the engine
6
+ class ApplicationController < ActionController::Base
7
+ # This controller is the base controller for all controllers in the engine
8
+ protect_from_forgery with: :exception
9
+ end
10
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveActions
4
+ # Main controller for handling reactive action requests
5
+ # Processes action execution and manages error handling for the ReactiveActions engine
6
+ class ReactiveActionsController < ApplicationController
7
+ # Allow this action to handle any HTTP method
8
+ def execute
9
+ ReactiveActions.logger.info "ReactiveActionsController#execute[#{request.method}]: #{params.inspect}"
10
+ build_reactive_action.run
11
+ rescue ReactiveActions::Error => e
12
+ handle_reactive_actions_error(e)
13
+ rescue StandardError => e
14
+ handle_standard_error(e)
15
+ end
16
+
17
+ private
18
+
19
+ # Build and initialize the action
20
+ def build_reactive_action
21
+ initialize_action(extract_action_name, extract_action_params)
22
+ end
23
+
24
+ # Extract and validate action_name parameter
25
+ def extract_action_name
26
+ action_name = params[:action_name]
27
+ raise ReactiveActions::MissingParameterError, 'Missing action_name parameter' if action_name.blank?
28
+
29
+ # Sanitize action_name to prevent code injection
30
+ sanitized_name = action_name.to_s.strip
31
+ raise ReactiveActions::InvalidParametersError, 'Invalid action_name format' unless sanitized_name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
32
+
33
+ sanitized_name
34
+ end
35
+
36
+ # Extract and sanitize action_params with proper strong parameters
37
+ def extract_action_params
38
+ raw_params = params[:action_params]
39
+ return {} if raw_params.blank?
40
+
41
+ # For JSON requests, the params might already be a hash
42
+ if raw_params.is_a?(String)
43
+ begin
44
+ parsed_params = JSON.parse(raw_params)
45
+ permit_nested_params(parsed_params)
46
+ rescue JSON::ParserError
47
+ raise ReactiveActions::InvalidParametersError, 'Invalid JSON in action_params'
48
+ end
49
+ else
50
+ # Handle ActionController::Parameters, Hash, or any other object
51
+ permit_nested_params(raw_params)
52
+ end
53
+ end
54
+
55
+ # Recursively permit nested parameters
56
+ # This allows for flexible parameter structures while maintaining security
57
+ def permit_nested_params(params)
58
+ case params
59
+ when ActionController::Parameters, Hash
60
+ permit_hash_params(params)
61
+ when Array
62
+ params.map { |item| permit_nested_params(item) }
63
+ else
64
+ sanitize_param_value(params)
65
+ end
66
+ end
67
+
68
+ # Handle hash-like parameters (ActionController::Parameters or Hash)
69
+ def permit_hash_params(params)
70
+ permitted_hash = {}
71
+ params.each do |key, value|
72
+ sanitized_key = sanitize_param_key(key)
73
+ next if sanitized_key.nil?
74
+
75
+ permitted_hash[sanitized_key] = permit_nested_params(value)
76
+ end
77
+ permitted_hash
78
+ end
79
+
80
+ # Sanitize parameter keys to prevent injection attacks
81
+ def sanitize_param_key(key)
82
+ key_str = key.to_s.strip
83
+
84
+ # Only allow alphanumeric characters, underscores, and hyphens
85
+ return nil unless key_str.match?(/\A[a-zA-Z0-9_-]+\z/)
86
+
87
+ # Prevent keys that start with dangerous prefixes
88
+ dangerous_prefixes = %w[__ eval exec system `]
89
+ return nil if dangerous_prefixes.any? { |prefix| key_str.start_with?(prefix) }
90
+
91
+ key_str
92
+ end
93
+
94
+ # Sanitize parameter values based on type
95
+ def sanitize_param_value(value)
96
+ return value if safe_primitive_type?(value)
97
+ return truncate_string(value) if value.is_a?(String)
98
+
99
+ # For other types, preserve original value if reasonable
100
+ value.respond_to?(:to_s) && !value.is_a?(Object) ? truncate_string(value.to_s) : value
101
+ end
102
+
103
+ # Check if value is a safe primitive type that doesn't need sanitization
104
+ def safe_primitive_type?(value)
105
+ value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass) ||
106
+ value.nil? || value.is_a?(Time) || value.is_a?(Date) || value.is_a?(DateTime)
107
+ end
108
+
109
+ # Truncate strings to prevent memory exhaustion
110
+ def truncate_string(string)
111
+ max_length = string == string.to_s ? 10_000 : 1_000
112
+ string.length > max_length ? string[0, max_length] : string
113
+ end
114
+
115
+ def handle_reactive_actions_error(exception)
116
+ error_mapping = {
117
+ 'ActionNotFoundError' => [:not_found, 'NOT_FOUND'],
118
+ 'MissingParameterError' => [:bad_request, 'MISSING_PARAMETER'],
119
+ 'InvalidParametersError' => [:bad_request, 'INVALID_PARAMETERS'],
120
+ 'UnauthorizedError' => [:forbidden, 'UNAUTHORIZED'],
121
+ 'ActionExecutionError' => [:unprocessable_entity, 'EXECUTION_ERROR']
122
+ }
123
+
124
+ error_type = exception.class.name.demodulize
125
+ status, code = error_mapping[error_type]
126
+
127
+ ReactiveActions.logger.error "#{error_type}: #{exception.message}"
128
+ render_error(exception, status, code)
129
+ end
130
+
131
+ def handle_standard_error(exception)
132
+ ReactiveActions.logger.error "Unexpected error: #{exception.message}"
133
+ render_error(exception, :internal_server_error, 'SERVER_ERROR')
134
+ end
135
+
136
+ # Helper method to render standardized error responses
137
+ def render_error(exception, status, code)
138
+ render json: {
139
+ success: false,
140
+ error: {
141
+ type: exception.class.name.demodulize,
142
+ message: exception.message,
143
+ code: code
144
+ }
145
+ }, status: status
146
+ end
147
+
148
+ # Find and execute the requested action
149
+ def initialize_action(action_name, action_params)
150
+ # Convert snake_case action name to CamelCase class name
151
+ # e.g., "update_user" becomes "UpdateUserAction"
152
+ class_name = build_class_name(action_name)
153
+
154
+ # Find the action class - this will look in several places:
155
+ # 1. ReactiveActions::{ClassName} (e.g., ReactiveActions::UpdateUserAction)
156
+ # 2. {ClassName} (e.g., UpdateUserAction) in global namespace
157
+ action_class = find_action_class(class_name)
158
+
159
+ # Initialize the action and return it
160
+ # Add ** to convert hash to keyword arguments
161
+ action_class.new(self, **action_params.symbolize_keys)
162
+ end
163
+
164
+ def build_class_name(action_name)
165
+ class_name = action_name.to_s.camelize
166
+ # Add "Action" suffix if it doesn't already end with it
167
+ class_name.end_with?('Action') ? class_name : "#{class_name}Action"
168
+ end
169
+
170
+ # Find an action class using various lookup strategies
171
+ def find_action_class(class_name)
172
+ # First try within the ReactiveActions namespace
173
+ "ReactiveActions::#{class_name}".constantize
174
+ rescue NameError
175
+ begin
176
+ # Then try in the global namespace
177
+ class_name.constantize
178
+ rescue NameError
179
+ raise ReactiveActions::ActionNotFoundError, "Action '#{class_name.sub(/Action$/, '')}' not found"
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ ReactiveActions.logger = Rails.logger
4
+ Rails.logger.level = :debug
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ ReactiveActions::Engine.routes.draw do
4
+ match '/execute', to: 'reactive_actions#execute', via: %i[get post put patch delete]
5
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module ReactiveActions
6
+ module Generators
7
+ # Interactive generator for installing ReactiveActions into a Rails application
8
+ # Prompts user for installation preferences and customization options
9
+ class InstallGenerator < Rails::Generators::Base
10
+ source_root File.expand_path('templates', __dir__)
11
+
12
+ desc 'Installs ReactiveActions with interactive configuration'
13
+
14
+ # Command line options
15
+ class_option :skip_routes, type: :boolean, default: false,
16
+ desc: 'Skip adding routes to the application'
17
+ class_option :skip_javascript, type: :boolean, default: false,
18
+ desc: 'Skip adding JavaScript imports'
19
+ class_option :skip_example, type: :boolean, default: false,
20
+ desc: 'Skip generating example action'
21
+ class_option :mount_path, type: :string, default: '/reactive_actions',
22
+ desc: 'Custom mount path for ReactiveActions'
23
+ class_option :quiet, type: :boolean, default: false,
24
+ desc: 'Run with minimal output'
25
+
26
+ def welcome_message
27
+ return if options[:quiet]
28
+
29
+ say 'Welcome to ReactiveActions installer!', :green
30
+ say 'This will help you set up ReactiveActions in your Rails application.'
31
+ say ''
32
+ end
33
+
34
+ def create_initializer
35
+ if options[:quiet] || yes?('Create ReactiveActions initializer? (recommended)', :green)
36
+ template 'initializer.rb', 'config/initializers/reactive_actions.rb'
37
+ say '✓ Created initializer', :green unless options[:quiet]
38
+ else
39
+ say '✗ Skipped initializer creation', :yellow
40
+ end
41
+ end
42
+
43
+ def create_actions_directory
44
+ if options[:quiet] || yes?('Create app/reactive_actions directory?', :green)
45
+ empty_directory 'app/reactive_actions'
46
+ say '✓ Created actions directory', :green unless options[:quiet]
47
+
48
+ ask_for_example_action unless options[:skip_example]
49
+ else
50
+ say '✗ Skipped actions directory creation', :yellow
51
+ end
52
+ end
53
+
54
+ def configure_routes
55
+ return if options[:skip_routes]
56
+
57
+ mount_path = determine_mount_path
58
+ return if mount_path.nil?
59
+
60
+ route "mount ReactiveActions::Engine, at: '#{mount_path}'"
61
+ say "✓ Added route mounting ReactiveActions at #{mount_path}", :green unless options[:quiet]
62
+ end
63
+
64
+ def configure_javascript
65
+ return if options[:skip_javascript]
66
+ return unless javascript_needed?
67
+
68
+ if options[:quiet] || yes?('Add ReactiveActions JavaScript client?', :green)
69
+ add_javascript_to_importmap
70
+ say '✓ Added JavaScript client to importmap', :green unless options[:quiet]
71
+ else
72
+ say '✗ Skipped JavaScript configuration', :yellow
73
+ end
74
+ end
75
+
76
+ def configuration_options
77
+ return if options[:quiet]
78
+ return unless yes?('Configure advanced options?', :green)
79
+
80
+ configure_delegated_methods
81
+ configure_logging
82
+ end
83
+
84
+ def installation_summary
85
+ return if options[:quiet]
86
+
87
+ say '', :green
88
+ say '=' * 60, :green
89
+ say 'ReactiveActions installation complete!', :bold
90
+ say '=' * 60, :green
91
+
92
+ show_usage_instructions
93
+ end
94
+
95
+ private
96
+
97
+ def should_skip_example_action?
98
+ return true if options[:skip_example]
99
+ return true if !options[:quiet] && !yes?('Generate example action file?', :green)
100
+
101
+ false
102
+ end
103
+
104
+ def example_action_name
105
+ if options[:quiet]
106
+ 'example'
107
+ else
108
+ ask('What should the example action be called?', default: 'example')
109
+ end
110
+ end
111
+
112
+ def sanitize_action_name(action_name)
113
+ sanitized = action_name.to_s.strip.underscore
114
+ sanitized = 'example' if sanitized.blank?
115
+ sanitized.end_with?('_action') ? sanitized : "#{sanitized}_action"
116
+ end
117
+
118
+ def determine_mount_path
119
+ return options[:mount_path] if options[:quiet]
120
+ return nil if options[:skip_routes]
121
+
122
+ if yes?('Add ReactiveActions routes to your application?', :green)
123
+ custom_path = ask('Mount path for ReactiveActions:', default: options[:mount_path])
124
+ sanitize_mount_path(custom_path)
125
+ else
126
+ say '✗ Skipped route configuration', :yellow
127
+ nil
128
+ end
129
+ end
130
+
131
+ def sanitize_mount_path(path)
132
+ sanitized = path.to_s.strip
133
+ sanitized = options[:mount_path] if sanitized.blank?
134
+ sanitized.start_with?('/') ? sanitized : "/#{sanitized}"
135
+ end
136
+
137
+ def javascript_needed?
138
+ using_importmap? || using_sprockets?
139
+ end
140
+
141
+ def using_importmap?
142
+ File.exist?('config/importmap.rb')
143
+ end
144
+
145
+ def using_sprockets?
146
+ File.exist?('app/assets/config/manifest.js')
147
+ end
148
+
149
+ def add_javascript_to_importmap
150
+ if using_importmap?
151
+ add_to_importmap
152
+ elsif using_sprockets?
153
+ add_to_sprockets_manifest
154
+ else
155
+ say 'No supported JavaScript setup detected (importmap or sprockets)', :yellow
156
+ end
157
+ end
158
+
159
+ def add_to_importmap
160
+ importmap_content = <<~IMPORTMAP
161
+
162
+ # ReactiveActions JavaScript client
163
+ pin "reactive_actions", to: "reactive_actions.js"
164
+ IMPORTMAP
165
+
166
+ append_to_file 'config/importmap.rb', importmap_content
167
+
168
+ # Add the import to application.js to ensure it executes
169
+ add_import_to_application_js
170
+ end
171
+
172
+ def add_import_to_application_js
173
+ app_js_paths = %w[
174
+ app/javascript/application.js
175
+ app/assets/javascripts/application.js
176
+ app/javascript/controllers/application.js
177
+ ]
178
+
179
+ app_js_path = app_js_paths.find { |path| File.exist?(path) }
180
+
181
+ if app_js_path
182
+ # Check if import already exists
183
+ app_js_content = File.read(app_js_path)
184
+ return if app_js_content.include?('import "reactive_actions"')
185
+
186
+ import_line = <<~JAVASCRIPT
187
+
188
+ // Import ReactiveActions to make it globally available
189
+ import "reactive_actions"
190
+ JAVASCRIPT
191
+
192
+ append_to_file app_js_path, import_line
193
+ say "✓ Added ReactiveActions import to #{app_js_path}", :green unless options[:quiet]
194
+ else
195
+ say '⚠ Could not find application.js file. Please manually add: import "reactive_actions"', :yellow
196
+ end
197
+ end
198
+
199
+ def add_to_sprockets_manifest
200
+ return unless File.exist?('app/assets/config/manifest.js')
201
+
202
+ append_to_file 'app/assets/config/manifest.js' do
203
+ "\n//= link reactive_actions.js\n"
204
+ end
205
+ end
206
+
207
+ def configure_delegated_methods
208
+ return unless yes?('Add custom controller methods to delegate to actions?', :green)
209
+
210
+ methods = ask('Enter method names (comma-separated):')
211
+ return if methods.blank?
212
+
213
+ method_array = methods.split(',').map(&:strip).map(&:to_sym)
214
+ add_custom_config('delegated_controller_methods', method_array)
215
+ end
216
+
217
+ def configure_logging
218
+ log_level = ask('Set logging level:',
219
+ limited_to: %w[debug info warn error fatal],
220
+ default: 'info')
221
+
222
+ add_custom_config('log_level', log_level) unless log_level == 'info'
223
+ end
224
+
225
+ def add_custom_config(option, value)
226
+ initializer_path = 'config/initializers/reactive_actions.rb'
227
+ return unless File.exist?(initializer_path)
228
+
229
+ config_line = build_config_line(option, value)
230
+ append_to_file initializer_path, "\n#{config_line}\n"
231
+ end
232
+
233
+ def build_config_line(option, value)
234
+ case option
235
+ when 'delegated_controller_methods'
236
+ " config.delegated_controller_methods += #{value}"
237
+ when 'log_level'
238
+ "ReactiveActions.logger.level = :#{value}"
239
+ end
240
+ end
241
+
242
+ def show_usage_instructions
243
+ if yes?('Show usage instructions?', :green)
244
+ readme 'README' if behavior == :invoke
245
+ else
246
+ say 'You can find usage instructions in the ReactiveActions documentation.'
247
+ end
248
+ end
249
+
250
+ def ask_for_example_action
251
+ return if should_skip_example_action?
252
+
253
+ action_name = example_action_name
254
+ sanitized_name = sanitize_action_name(action_name)
255
+
256
+ template 'example_action.rb', "app/reactive_actions/#{sanitized_name}.rb"
257
+ say "✓ Created #{sanitized_name}.rb", :green unless options[:quiet]
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,32 @@
1
+ ===============================================================================
2
+
3
+ ReactiveActions has been installed successfully!
4
+
5
+ Routes have been added to your config/routes.rb file:
6
+ mount ReactiveActions::Engine, at: '/reactive_actions'
7
+
8
+ You can now access the reactive actions functionality at:
9
+ http://localhost:3000/reactive_actions/execute
10
+
11
+ A sample action has been created at:
12
+ app/reactive_actions/example_action.rb
13
+
14
+ You can try it out with:
15
+
16
+ # Using HTTP
17
+ curl -X POST http://localhost:3000/reactive_actions/execute \
18
+ -H "Content-Type: application/json" \
19
+ -d '{"action_name": "example", "action_params": {"name": "YourName"}}'
20
+
21
+ # Using JavaScript client
22
+ ReactiveActions.execute('example', { name: 'YourName' })
23
+ .then(response => console.log(response))
24
+
25
+ To create your own actions, add more files to the app/reactive_actions directory.
26
+ Each action should inherit from ReactiveActions::ReactiveAction and define
27
+ at least the `action` and `response` methods.
28
+
29
+ For more information, see the documentation at:
30
+ https://github.com/IstvanMs/reactive-actions
31
+
32
+ ===============================================================================
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example reactive action
4
+ # This action demonstrates the basic structure of a reactive action
5
+ class ExampleAction < ReactiveActions::ReactiveAction
6
+ # The main action logic goes here
7
+ def action
8
+ # You can access action parameters via the action_params
9
+ name = action_params[:name] || 'World'
10
+
11
+ # Generate a result hash (optional)
12
+ @result = {
13
+ status: :success,
14
+ message: "Hello, #{name}!"
15
+ }
16
+ end
17
+
18
+ # Define how to respond to the client
19
+ def response
20
+ # You can use controller methods like render
21
+ render json: {
22
+ success: true,
23
+ data: @result
24
+ }
25
+ end
26
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ReactiveActions configuration
4
+ ReactiveActions.configure do |config|
5
+ # Configure methods to delegate from the controller to action classes
6
+ # config.delegated_controller_methods += [:custom_method]
7
+
8
+ # Configure instance variables to delegate from the controller to action classes
9
+ # config.delegated_instance_variables += [:custom_variable]
10
+ end
11
+
12
+ # Set the logger for ReactiveActions
13
+ ReactiveActions.logger = Rails.logger
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'reactive_actions'