propel_api 0.3.1.6 ā 0.3.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/CHANGELOG.md +83 -0
- data/README.md +46 -1
- data/lib/generators/propel_api/controller/controller_generator.rb +13 -7
- data/lib/generators/propel_api/core/named_base.rb +1191 -103
- data/lib/generators/propel_api/install/install_generator.rb +43 -2
- data/lib/generators/propel_api/resource/resource_generator.rb +173 -61
- data/lib/generators/propel_api/templates/concerns/propel_controller_filters_concern.rb +141 -0
- data/lib/generators/propel_api/templates/concerns/propel_model_filters_concern.rb +128 -0
- data/lib/generators/propel_api/templates/controllers/api_controller_propel_facets.rb +1 -0
- data/lib/generators/propel_api/templates/lib/XX_dynamic_scope_generator.rb +360 -0
- data/lib/generators/propel_api/templates/lib/propel_dynamic_scope_generator.rb +474 -0
- data/lib/generators/propel_api/templates/lib/propel_filter_operators.rb +16 -0
- data/lib/generators/propel_api/templates/scaffold/facet_model_template.rb.tt +14 -0
- data/lib/generators/propel_api/templates/seeds/seeds_template.rb.tt +100 -6
- data/lib/generators/propel_api/templates/tests/controller_test_template.rb.tt +9 -0
- data/lib/generators/propel_api/templates/tests/integration_test_template.rb.tt +660 -10
- data/lib/generators/propel_api/templates/tests/model_test_template.rb.tt +7 -0
- data/lib/propel_api.rb +1 -1
- metadata +11 -5
@@ -37,6 +37,9 @@ module PropelApi
|
|
37
37
|
|
38
38
|
# Optionally enhance CSRF error messages (non-breaking)
|
39
39
|
add_csrf_error_handling
|
40
|
+
|
41
|
+
# Add dynamic filtering capabilities to ApplicationRecord
|
42
|
+
add_model_filters_concern
|
40
43
|
end
|
41
44
|
|
42
45
|
case @adapter
|
@@ -113,6 +116,28 @@ module PropelApi
|
|
113
116
|
|
114
117
|
private
|
115
118
|
|
119
|
+
def copy_propel_facets_concerns
|
120
|
+
if behavior == :revoke
|
121
|
+
# Remove concerns
|
122
|
+
remove_file "app/controllers/concerns/propel_controller_filters_concern.rb"
|
123
|
+
remove_file "app/models/concerns/propel_model_filters_concern.rb"
|
124
|
+
remove_file "lib/propel_filter_operators.rb"
|
125
|
+
remove_file "lib/propel_dynamic_scope_generator.rb"
|
126
|
+
say "šļø Removed PropelApi filtering concerns", :red
|
127
|
+
else
|
128
|
+
# Copy concerns to host app using XX-prefixed source files
|
129
|
+
copy_file "concerns/propel_controller_filters_concern.rb", "app/controllers/concerns/propel_controller_filters_concern.rb"
|
130
|
+
copy_file "concerns/propel_model_filters_concern.rb", "app/models/concerns/propel_model_filters_concern.rb"
|
131
|
+
copy_file "lib/propel_filter_operators.rb", "lib/propel_filter_operators.rb"
|
132
|
+
copy_file "lib/propel_dynamic_scope_generator.rb", "lib/propel_dynamic_scope_generator.rb"
|
133
|
+
say "š¦ Copied PropelApi filtering concerns to app", :green
|
134
|
+
say " - Controller filtering: app/controllers/concerns/propel_controller_filters_concern.rb", :blue
|
135
|
+
say " - Model filtering: app/models/concerns/propel_model_filters_concern.rb", :blue
|
136
|
+
say " - Filter operators: lib/propel_filter_operators.rb", :blue
|
137
|
+
say " - Dynamic scope generator: lib/propel_dynamic_scope_generator.rb", :blue
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
116
141
|
def create_api_base_controller
|
117
142
|
template "controllers/api_base_controller.rb", "app/controllers/api/base_controller.rb"
|
118
143
|
say "Created Api::BaseController for CSRF-free API endpoints", :green
|
@@ -144,6 +169,18 @@ module PropelApi
|
|
144
169
|
say "You can manually add the rescue_from handler if desired", :blue
|
145
170
|
end
|
146
171
|
|
172
|
+
def add_model_filters_concern
|
173
|
+
# Inject PropelModelFiltersConcern into ApplicationRecord
|
174
|
+
inject_into_class "app/models/application_record.rb", ApplicationRecord, <<-RUBY
|
175
|
+
include PropelModelFiltersConcern
|
176
|
+
RUBY
|
177
|
+
|
178
|
+
say "Enhanced ApplicationRecord with dynamic filtering capabilities", :green
|
179
|
+
rescue => e
|
180
|
+
say "Could not inject PropelModelFiltersConcern into ApplicationRecord: #{e.message}", :yellow
|
181
|
+
say "You can manually add 'include PropelModelFiltersConcern' to ApplicationRecord if desired", :blue
|
182
|
+
end
|
183
|
+
|
147
184
|
def copy_propel_facets_controller
|
148
185
|
template "controllers/api_controller_propel_facets.rb", api_controller_path
|
149
186
|
copy_example_controller
|
@@ -256,8 +293,12 @@ module PropelApi
|
|
256
293
|
say " end", :yellow
|
257
294
|
say "\n5. See doc/api_controller_example.rb for complete usage examples"
|
258
295
|
say "6. Your API endpoints will be available at: #{api_route_prefix}/..."
|
259
|
-
say "7. Pagination and filtering are built-in via Pagy and
|
260
|
-
say "
|
296
|
+
say "7. Pagination and secure filtering are built-in via Pagy and PropelApi filtering"
|
297
|
+
say "8. Filtering examples:"
|
298
|
+
say " GET /api/v1/users?name_contains=john&active_eq=true&sort=-created_at", :yellow
|
299
|
+
say " - Secure field validation prevents SQL injection"
|
300
|
+
say " - Sensitive fields (passwords, tokens) automatically excluded", :cyan
|
301
|
+
say "\n9. Generate resources using:"
|
261
302
|
say " rails generate propel_api Resource field1:type field2:type", :yellow
|
262
303
|
end
|
263
304
|
|
@@ -80,47 +80,11 @@ module PropelApi
|
|
80
80
|
initialize_propel_api_settings
|
81
81
|
|
82
82
|
if behavior == :revoke
|
83
|
-
#
|
84
|
-
|
83
|
+
# Check for critical dependencies before destroying
|
84
|
+
check_for_critical_dependencies
|
85
85
|
|
86
|
-
|
87
|
-
|
88
|
-
migration_version = File.basename(migration_file).split('_').first
|
89
|
-
|
90
|
-
# Check if migration was executed
|
91
|
-
if migration_was_executed?(migration_version)
|
92
|
-
say "\n" + "="*80, :red
|
93
|
-
say "ā ļø MIGRATION ALREADY EXECUTED!", :red
|
94
|
-
say "="*80, :red
|
95
|
-
say "\nš Migration #{migration_version} (create_#{table_name}) has been executed.", :yellow
|
96
|
-
say "\nšØ Rails does not automatically rollback migrations for safety reasons.", :yellow
|
97
|
-
say "\nā
To safely destroy this resource:", :green
|
98
|
-
say " 1. First rollback: rails db:rollback", :green
|
99
|
-
say " 2. Then destroy: rails destroy propel_api #{class_name}", :green
|
100
|
-
say "\nš” Alternative approaches:", :cyan
|
101
|
-
say " ⢠Check migration status: rails db:migrate:status"
|
102
|
-
say " ⢠Rollback specific: rails db:migrate:down VERSION=#{migration_version}"
|
103
|
-
say "\n" + "="*80, :red
|
104
|
-
raise Thor::Error, "Please rollback the migration first, then re-run destroy command."
|
105
|
-
else
|
106
|
-
# Migration not executed, check if safe to delete
|
107
|
-
if safe_to_delete_migration?(migration_version)
|
108
|
-
say "Removing migration: #{File.basename(migration_file)}", :red
|
109
|
-
File.delete(migration_file)
|
110
|
-
else
|
111
|
-
say "\n" + "="*80, :yellow
|
112
|
-
say "ā ļø MIGRATION CLEANUP REQUIRED", :yellow
|
113
|
-
say "="*80, :yellow
|
114
|
-
say "\nš Found unexecuted migration: #{File.basename(migration_file)}", :cyan
|
115
|
-
say "\nšØ Cannot auto-delete due to intervening migrations.", :yellow
|
116
|
-
say "š” Please manually remove when safe:", :green
|
117
|
-
say " rm #{migration_file}"
|
118
|
-
say "\n" + "="*80, :yellow
|
119
|
-
end
|
120
|
-
end
|
121
|
-
else
|
122
|
-
say "No migration file found for #{table_name}", :yellow
|
123
|
-
end
|
86
|
+
# Generate proper removal migration following Rails conventions
|
87
|
+
generate_removal_migration
|
124
88
|
else
|
125
89
|
# Check for tenancy and warn if missing (unless warnings are disabled)
|
126
90
|
check_tenancy_attributes_and_warn unless options[:skip_tenancy]
|
@@ -154,32 +118,49 @@ module PropelApi
|
|
154
118
|
validate_attributes_exist
|
155
119
|
end
|
156
120
|
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
121
|
+
# Note: Relationship cleanup is now handled by the comprehensive dependency auto-fix system
|
122
|
+
# No need for separate inverse relationship logic here
|
123
|
+
|
124
|
+
# Direct template call - Rails can reverse this automatically (unless skipped)!
|
125
|
+
unless behavior == :revoke && options[:skip_model]
|
126
|
+
case @adapter
|
127
|
+
when 'propel_facets'
|
128
|
+
template "scaffold/facet_model_template.rb.tt", "app/models/#{file_name}.rb"
|
129
|
+
when 'graphiti'
|
130
|
+
template "scaffold/graphiti_model_template.rb.tt", "app/models/#{file_name}.rb"
|
131
|
+
else
|
132
|
+
raise "Unknown adapter: #{@adapter}. Run 'rails generate propel_api:install' first."
|
133
|
+
end
|
164
134
|
end
|
165
135
|
|
166
|
-
# Apply inverse relationships
|
167
|
-
|
136
|
+
# Apply inverse relationships AFTER template creation
|
137
|
+
if behavior != :revoke
|
138
|
+
apply_inverse_relationships
|
139
|
+
end
|
168
140
|
end
|
169
141
|
|
170
142
|
def create_controller
|
171
|
-
|
172
|
-
create_propel_controller
|
143
|
+
create_propel_controller_template
|
173
144
|
end
|
174
145
|
|
175
|
-
def
|
176
|
-
# Use shared method from Base class
|
146
|
+
def add_routes
|
177
147
|
create_propel_routes
|
178
148
|
end
|
179
149
|
|
180
|
-
def
|
181
|
-
|
182
|
-
|
150
|
+
def create_model_test
|
151
|
+
create_propel_model_test unless behavior == :revoke && options[:skip_tests]
|
152
|
+
end
|
153
|
+
|
154
|
+
def create_controller_test
|
155
|
+
create_propel_controller_test unless behavior == :revoke && options[:skip_tests]
|
156
|
+
end
|
157
|
+
|
158
|
+
def create_integration_test
|
159
|
+
create_propel_integration_test unless behavior == :revoke && options[:skip_tests]
|
160
|
+
end
|
161
|
+
|
162
|
+
def create_fixtures
|
163
|
+
create_propel_fixtures unless behavior == :revoke && options[:skip_tests]
|
183
164
|
end
|
184
165
|
|
185
166
|
def create_seeds
|
@@ -288,13 +269,20 @@ module PropelApi
|
|
288
269
|
next_steps << "Customize facets in: app/models/#{file_name}.rb"
|
289
270
|
end
|
290
271
|
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
272
|
+
if behavior == :revoke
|
273
|
+
# Use shared destroy completion message from named_base
|
274
|
+
show_destroy_completion_message
|
275
|
+
else
|
276
|
+
show_propel_completion_message(
|
277
|
+
resource_type: "Resource",
|
278
|
+
generated_files: generated_files,
|
279
|
+
next_steps: next_steps
|
280
|
+
)
|
281
|
+
end
|
296
282
|
end
|
297
283
|
|
284
|
+
|
285
|
+
|
298
286
|
private
|
299
287
|
|
300
288
|
def relationship_inferrer
|
@@ -419,6 +407,130 @@ module PropelApi
|
|
419
407
|
end
|
420
408
|
end
|
421
409
|
|
410
|
+
def remove_inverse_relationships
|
411
|
+
# Use Rails model reflection - much cleaner approach!
|
412
|
+
begin
|
413
|
+
model_class = class_name.constantize
|
414
|
+
|
415
|
+
# Get all belongs_to associations using Rails reflection
|
416
|
+
belongs_to_associations = model_class.reflect_on_all_associations(:belongs_to)
|
417
|
+
|
418
|
+
belongs_to_associations.each do |association|
|
419
|
+
# Skip polymorphic associations (they don't get inverse relationships)
|
420
|
+
next if association.polymorphic?
|
421
|
+
|
422
|
+
parent_class_name = association.class_name
|
423
|
+
parent_model_file = "app/models/#{parent_class_name.underscore}.rb"
|
424
|
+
full_path = File.join(destination_root, parent_model_file)
|
425
|
+
|
426
|
+
# Skip if parent model doesn't exist
|
427
|
+
next unless File.exist?(full_path)
|
428
|
+
|
429
|
+
# The inverse relationship we need to remove
|
430
|
+
inverse_relationship = "has_many :#{table_name}, dependent: :destroy"
|
431
|
+
|
432
|
+
begin
|
433
|
+
remove_specific_relationship(full_path, inverse_relationship, parent_class_name)
|
434
|
+
rescue => e
|
435
|
+
say "Warning: Could not remove relationship from #{parent_model_file}: #{e.message}", :yellow
|
436
|
+
end
|
437
|
+
end
|
438
|
+
|
439
|
+
rescue NameError => e
|
440
|
+
# Fallback: Parse attributes from migration file if model class doesn't exist
|
441
|
+
migration_attributes = get_attributes_from_migration
|
442
|
+
belongs_to_attributes = migration_attributes.select { |attr| attr[:type] == :references }
|
443
|
+
|
444
|
+
belongs_to_attributes.each do |attr|
|
445
|
+
# Skip polymorphic references
|
446
|
+
next if attr[:polymorphic]
|
447
|
+
|
448
|
+
parent_class_name = attr[:name].classify
|
449
|
+
parent_model_file = "app/models/#{parent_class_name.underscore}.rb"
|
450
|
+
full_path = File.join(destination_root, parent_model_file)
|
451
|
+
|
452
|
+
# Skip if parent model doesn't exist
|
453
|
+
next unless File.exist?(full_path)
|
454
|
+
|
455
|
+
inverse_relationship = "has_many :#{table_name}, dependent: :destroy"
|
456
|
+
|
457
|
+
begin
|
458
|
+
remove_specific_relationship(full_path, inverse_relationship, parent_class_name)
|
459
|
+
rescue => e
|
460
|
+
say "Warning: Could not remove relationship from #{parent_model_file}: #{e.message}", :yellow
|
461
|
+
end
|
462
|
+
end
|
463
|
+
end
|
464
|
+
end
|
465
|
+
|
466
|
+
def get_attributes_from_migration
|
467
|
+
# Find the migration file (using same pattern as create_migration)
|
468
|
+
migration_files = Dir[File.join(destination_root, "db/migrate/*_create_#{table_name}.rb")]
|
469
|
+
return [] unless migration_files.any?
|
470
|
+
|
471
|
+
migration_file = migration_files.first
|
472
|
+
migration_content = File.read(migration_file)
|
473
|
+
|
474
|
+
# Parse t.references and t.string, t.text, etc. lines
|
475
|
+
parsed_attributes = []
|
476
|
+
|
477
|
+
# Match references with polymorphic option
|
478
|
+
migration_content.scan(/t\.references\s+:(\w+).*?(polymorphic:\s*true)?/) do |name, polymorphic|
|
479
|
+
parsed_attributes << {
|
480
|
+
name: name,
|
481
|
+
type: :references,
|
482
|
+
polymorphic: !polymorphic.nil?
|
483
|
+
}
|
484
|
+
end
|
485
|
+
|
486
|
+
# Match other attribute types
|
487
|
+
%w[string text integer decimal boolean datetime date json jsonb].each do |type|
|
488
|
+
migration_content.scan(/t\.#{type}\s+:(\w+)/) do |name|
|
489
|
+
parsed_attributes << {
|
490
|
+
name: name.first,
|
491
|
+
type: type.to_sym,
|
492
|
+
polymorphic: false
|
493
|
+
}
|
494
|
+
end
|
495
|
+
end
|
496
|
+
|
497
|
+
parsed_attributes
|
498
|
+
end
|
499
|
+
|
500
|
+
def remove_specific_relationship(model_file_path, relationship, parent_class_name)
|
501
|
+
content = File.read(model_file_path)
|
502
|
+
original_content = content.dup
|
503
|
+
|
504
|
+
# Remove the relationship line if it exists
|
505
|
+
relationship_pattern = /^\s*#{Regexp.escape(relationship)}\s*\n/
|
506
|
+
if content.match?(relationship_pattern)
|
507
|
+
content = content.gsub(relationship_pattern, '')
|
508
|
+
File.write(model_file_path, content)
|
509
|
+
say " ā Removed from #{parent_class_name}: #{relationship}", :red
|
510
|
+
else
|
511
|
+
say " ā ļø Relationship not found in #{parent_class_name}: #{relationship}", :yellow
|
512
|
+
end
|
513
|
+
end
|
514
|
+
|
515
|
+
def remove_from_parent_model(model_file_path, relationships, parent_class_name)
|
516
|
+
content = File.read(model_file_path)
|
517
|
+
original_content = content.dup
|
518
|
+
|
519
|
+
relationships.each do |relationship|
|
520
|
+
# Remove the relationship line if it exists
|
521
|
+
relationship_pattern = /^\s*#{Regexp.escape(relationship)}\s*\n/
|
522
|
+
if content.match?(relationship_pattern)
|
523
|
+
content = content.gsub(relationship_pattern, '')
|
524
|
+
say " ā Removed from #{parent_class_name}: #{relationship}", :red
|
525
|
+
end
|
526
|
+
end
|
527
|
+
|
528
|
+
# Only write if content changed
|
529
|
+
if content != original_content
|
530
|
+
File.write(model_file_path, content)
|
531
|
+
end
|
532
|
+
end
|
533
|
+
|
422
534
|
def update_parent_model(model_file_path, relationships, parent_class_name)
|
423
535
|
content = File.read(model_file_path)
|
424
536
|
original_content = content.dup
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# DynamicControllerFiltersConcern
|
4
|
+
#
|
5
|
+
# Provides dynamic filtering capabilities for API controllers using has_scope.
|
6
|
+
# This concern automatically generates and registers scopes based on the model's
|
7
|
+
# database columns and their types.
|
8
|
+
#
|
9
|
+
# Features:
|
10
|
+
# - Automatic scope generation based on column types
|
11
|
+
# - Security validation of filter parameters
|
12
|
+
# - Error handling for malformed filters
|
13
|
+
# - Support for all standard filter operators
|
14
|
+
#
|
15
|
+
# Usage:
|
16
|
+
# class Api::V1::TeamsController < Api::V1::ApiController
|
17
|
+
# include PropelControllerFiltersConcern
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
module PropelControllerFiltersConcern
|
21
|
+
extend ActiveSupport::Concern
|
22
|
+
|
23
|
+
included do
|
24
|
+
include HasScope
|
25
|
+
|
26
|
+
# Track which scopes have been registered to avoid duplicates
|
27
|
+
class_attribute :_registered_scopes, default: Set.new
|
28
|
+
|
29
|
+
# Register dynamic scopes when the controller is first used
|
30
|
+
before_action :ensure_dynamic_scopes_registered, prepend: true
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def ensure_dynamic_scopes_registered
|
36
|
+
return if self.class._registered_scopes.include?(resource_class.name)
|
37
|
+
|
38
|
+
model_class = resource_class
|
39
|
+
return unless model_class&.respond_to?(:columns)
|
40
|
+
|
41
|
+
# Ensure scopes are generated for the model (lazy loading)
|
42
|
+
model_class.ensure_scopes_generated!
|
43
|
+
|
44
|
+
# Generate scopes for the model
|
45
|
+
generator = PropelDynamicScopeGenerator.new(model_class)
|
46
|
+
generator.generate_scopes!
|
47
|
+
|
48
|
+
# Register has_scope calls for this controller
|
49
|
+
generator.register_controller_scopes(self.class)
|
50
|
+
|
51
|
+
# Mark as registered to avoid duplicate work
|
52
|
+
self.class._registered_scopes << model_class.name
|
53
|
+
rescue StandardError => e
|
54
|
+
Rails.logger.error "Error registering dynamic scopes: #{e.message}"
|
55
|
+
Rails.logger.error e.backtrace.join("\n")
|
56
|
+
end
|
57
|
+
|
58
|
+
# Override apply_scopes to add security validation and error handling
|
59
|
+
def apply_scopes(base_scope)
|
60
|
+
# Filter out any potentially dangerous or unsupported parameters
|
61
|
+
safe_params = filter_safe_parameters(params)
|
62
|
+
|
63
|
+
# Apply scopes with error handling
|
64
|
+
result = super(base_scope, safe_params)
|
65
|
+
result || base_scope
|
66
|
+
rescue StandardError => e
|
67
|
+
Rails.logger.warn "Error in apply_scopes: #{e.message}"
|
68
|
+
base_scope
|
69
|
+
end
|
70
|
+
|
71
|
+
# Security: Filter out dangerous or unsupported parameters
|
72
|
+
def filter_safe_parameters(params)
|
73
|
+
return {} unless params.is_a?(ActionController::Parameters)
|
74
|
+
|
75
|
+
# Get allowed filter parameters based on model columns
|
76
|
+
allowed_filters = allowed_filter_parameters
|
77
|
+
|
78
|
+
# Filter to only include known, safe parameters
|
79
|
+
safe_params = params.slice(*allowed_filters)
|
80
|
+
|
81
|
+
# Log any filtered out parameters for security monitoring
|
82
|
+
filtered_params = params.except(*allowed_filters).except(:controller, :action, :format)
|
83
|
+
if filtered_params.present?
|
84
|
+
Rails.logger.warn "Filtered out potentially unsafe parameters: #{filtered_params.keys.join(', ')}"
|
85
|
+
end
|
86
|
+
|
87
|
+
safe_params
|
88
|
+
end
|
89
|
+
|
90
|
+
# Get list of allowed filter parameters based on model columns
|
91
|
+
def allowed_filter_parameters
|
92
|
+
return [] unless resource_class&.respond_to?(:columns)
|
93
|
+
|
94
|
+
allowed = []
|
95
|
+
|
96
|
+
resource_class.columns.each do |column|
|
97
|
+
name = column.name
|
98
|
+
type = column.type
|
99
|
+
sql_type = column.sql_type_metadata.sql_type
|
100
|
+
|
101
|
+
# Skip sensitive fields
|
102
|
+
next if sensitive_field?(name)
|
103
|
+
|
104
|
+
# Get operators for this column type
|
105
|
+
operators = PropelFilterOperators.operators_for(type, sql_type)
|
106
|
+
|
107
|
+
# Add parameter names for each operator
|
108
|
+
operators.each do |operator|
|
109
|
+
allowed << "#{name}_#{operator}".to_sym
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Add special parameters
|
114
|
+
allowed += [:order_by, :page, :limit, :per_page]
|
115
|
+
|
116
|
+
allowed
|
117
|
+
end
|
118
|
+
|
119
|
+
# Check if a field name is considered sensitive and should not be filterable
|
120
|
+
def sensitive_field?(field_name)
|
121
|
+
sensitive_fields = %w[
|
122
|
+
password password_hash password_digest
|
123
|
+
token secret_key api_key
|
124
|
+
encrypted_data encrypted_password
|
125
|
+
salt pepper
|
126
|
+
private_key public_key
|
127
|
+
ssn social_security_number
|
128
|
+
credit_card_number
|
129
|
+
bank_account_number
|
130
|
+
]
|
131
|
+
|
132
|
+
sensitive_fields.any? { |sensitive| field_name.downcase.include?(sensitive) }
|
133
|
+
end
|
134
|
+
|
135
|
+
# Override to provide better error messages for invalid filters
|
136
|
+
def handle_invalid_filter(filter_name, error)
|
137
|
+
Rails.logger.warn "Invalid filter '#{filter_name}': #{error.message}"
|
138
|
+
# Don't raise the error, just log it and continue
|
139
|
+
# This prevents one bad filter from breaking the entire request
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# DynamicModelFiltersConcern
|
4
|
+
#
|
5
|
+
# Provides dynamic filtering capabilities for ActiveRecord models.
|
6
|
+
# This concern automatically generates scopes based on the model's
|
7
|
+
# database columns and their types.
|
8
|
+
#
|
9
|
+
# Features:
|
10
|
+
# - Automatic scope generation based on column types
|
11
|
+
# - Support for all standard filter operators
|
12
|
+
# - Type-aware filtering (boolean, string, numeric, datetime)
|
13
|
+
# - Security validation of filter parameters
|
14
|
+
#
|
15
|
+
# Usage:
|
16
|
+
# class Team < ApplicationRecord
|
17
|
+
# include PropelModelFiltersConcern
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
module PropelModelFiltersConcern
|
21
|
+
extend ActiveSupport::Concern
|
22
|
+
|
23
|
+
included do
|
24
|
+
# Track which scopes have been generated to avoid duplicates
|
25
|
+
class_attribute :_generated_scopes, default: Set.new
|
26
|
+
|
27
|
+
# Generate scopes lazily when first needed
|
28
|
+
# This prevents issues with abstract classes like ApplicationRecord
|
29
|
+
end
|
30
|
+
|
31
|
+
class_methods do
|
32
|
+
# Generate all dynamic scopes for this model
|
33
|
+
def generate_dynamic_scopes!
|
34
|
+
return if _generated_scopes.include?(name)
|
35
|
+
|
36
|
+
# Skip abstract classes (like ApplicationRecord)
|
37
|
+
return if abstract_class?
|
38
|
+
|
39
|
+
# Skip if the model doesn't have a table (e.g., abstract classes)
|
40
|
+
return unless table_exists?
|
41
|
+
|
42
|
+
generator = PropelDynamicScopeGenerator.new(self)
|
43
|
+
generator.generate_scopes!
|
44
|
+
|
45
|
+
# Mark as generated to avoid duplicate work
|
46
|
+
_generated_scopes << name
|
47
|
+
rescue StandardError => e
|
48
|
+
Rails.logger.error "Error generating dynamic scopes for #{name}: #{e.message}"
|
49
|
+
Rails.logger.error e.backtrace.join("\n")
|
50
|
+
end
|
51
|
+
|
52
|
+
# Ensure scopes are generated (lazy loading)
|
53
|
+
def ensure_scopes_generated!
|
54
|
+
generate_dynamic_scopes! unless _generated_scopes.include?(name)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Get all available filter parameters for this model
|
58
|
+
def available_filter_parameters
|
59
|
+
return [] unless respond_to?(:columns)
|
60
|
+
|
61
|
+
allowed = []
|
62
|
+
|
63
|
+
columns.each do |column|
|
64
|
+
name = column.name
|
65
|
+
type = column.type
|
66
|
+
sql_type = column.sql_type_metadata.sql_type
|
67
|
+
|
68
|
+
# Skip sensitive fields
|
69
|
+
next if sensitive_field?(name)
|
70
|
+
|
71
|
+
# Get operators for this column type
|
72
|
+
operators = PropelFilterOperators.operators_for(type, sql_type)
|
73
|
+
|
74
|
+
# Add parameter names for each operator
|
75
|
+
operators.each do |operator|
|
76
|
+
allowed << "#{name}_#{operator}".to_sym
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
allowed
|
81
|
+
end
|
82
|
+
|
83
|
+
# Check if a field name is considered sensitive
|
84
|
+
def sensitive_field?(field_name)
|
85
|
+
sensitive_fields = %w[
|
86
|
+
password password_hash password_digest
|
87
|
+
token secret_key api_key
|
88
|
+
encrypted_data encrypted_password
|
89
|
+
salt pepper
|
90
|
+
private_key public_key
|
91
|
+
ssn social_security_number
|
92
|
+
credit_card_number
|
93
|
+
bank_account_number
|
94
|
+
]
|
95
|
+
|
96
|
+
sensitive_fields.any? { |sensitive| field_name.downcase.include?(sensitive) }
|
97
|
+
end
|
98
|
+
|
99
|
+
# Get filterable columns for this model
|
100
|
+
def filterable_columns
|
101
|
+
return [] unless respond_to?(:columns)
|
102
|
+
|
103
|
+
columns.reject { |column| sensitive_field?(column.name) }
|
104
|
+
end
|
105
|
+
|
106
|
+
# Get operators available for a specific column
|
107
|
+
def operators_for_column(column_name)
|
108
|
+
column = columns.find { |c| c.name == column_name }
|
109
|
+
return [] unless column
|
110
|
+
|
111
|
+
PropelFilterOperators.operators_for(column.type, column.sql_type_metadata.sql_type)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Validate filter parameters
|
115
|
+
def validate_filter_parameters(params)
|
116
|
+
return {} unless params.is_a?(ActionController::Parameters)
|
117
|
+
|
118
|
+
allowed_filters = available_filter_parameters
|
119
|
+
params.slice(*allowed_filters)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
def ensure_dynamic_scopes_generated
|
126
|
+
self.class.generate_dynamic_scopes!
|
127
|
+
end
|
128
|
+
end
|
@@ -4,6 +4,7 @@ class <%= api_controller_class_name %> < Api::BaseController
|
|
4
4
|
include FacetRenderer
|
5
5
|
include StrongParamsHelper
|
6
6
|
include PropelAuthenticationConcern
|
7
|
+
include PropelControllerFiltersConcern
|
7
8
|
|
8
9
|
before_action :authenticate_user
|
9
10
|
before_action :set_resource, only: [:show, :update, :destroy]
|