propel_api 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +59 -0
- data/LICENSE +21 -0
- data/README.md +320 -0
- data/Rakefile +36 -0
- data/lib/generators/propel_api/USAGE +8 -0
- data/lib/generators/propel_api/controller/controller_generator.rb +208 -0
- data/lib/generators/propel_api/core/base.rb +19 -0
- data/lib/generators/propel_api/core/configuration_methods.rb +187 -0
- data/lib/generators/propel_api/core/named_base.rb +457 -0
- data/lib/generators/propel_api/core/path_generation_methods.rb +45 -0
- data/lib/generators/propel_api/core/relationship_inferrer.rb +117 -0
- data/lib/generators/propel_api/install/install_generator.rb +343 -0
- data/lib/generators/propel_api/resource/resource_generator.rb +433 -0
- data/lib/generators/propel_api/templates/config/propel_api.rb.tt +149 -0
- data/lib/generators/propel_api/templates/controllers/api_controller_graphiti.rb +79 -0
- data/lib/generators/propel_api/templates/controllers/api_controller_propel_facets.rb +76 -0
- data/lib/generators/propel_api/templates/controllers/example_controller.rb.tt +96 -0
- data/lib/generators/propel_api/templates/scaffold/facet_controller_template.rb.tt +80 -0
- data/lib/generators/propel_api/templates/scaffold/facet_model_template.rb.tt +141 -0
- data/lib/generators/propel_api/templates/scaffold/graphiti_controller_template.rb.tt +82 -0
- data/lib/generators/propel_api/templates/scaffold/graphiti_model_template.rb.tt +32 -0
- data/lib/generators/propel_api/templates/seeds/seeds_template.rb.tt +493 -0
- data/lib/generators/propel_api/templates/tests/controller_test_template.rb.tt +485 -0
- data/lib/generators/propel_api/templates/tests/fixtures_template.yml.tt +250 -0
- data/lib/generators/propel_api/templates/tests/integration_test_template.rb.tt +487 -0
- data/lib/generators/propel_api/templates/tests/model_test_template.rb.tt +252 -0
- data/lib/generators/propel_api/unpack/unpack_generator.rb +304 -0
- data/lib/propel_api.rb +3 -0
- metadata +95 -0
@@ -0,0 +1,433 @@
|
|
1
|
+
require_relative '../core/relationship_inferrer'
|
2
|
+
require_relative '../core/named_base'
|
3
|
+
|
4
|
+
module PropelApi
|
5
|
+
class ResourceGenerator < PropelApi::NamedBase
|
6
|
+
source_root File.expand_path("../templates", __dir__)
|
7
|
+
|
8
|
+
desc <<~DESC
|
9
|
+
Generate a complete API resource with model, migration, controller, and routes
|
10
|
+
|
11
|
+
ASSOCIATION EXAMPLES:
|
12
|
+
|
13
|
+
Standard belongs_to/has_many (automatic):
|
14
|
+
rails generate propel_api Article title:string user:references category:references
|
15
|
+
→ Article belongs_to :user, :category
|
16
|
+
→ User/Category has_many :articles, dependent: :destroy
|
17
|
+
|
18
|
+
Polymorphic associations:
|
19
|
+
rails generate propel_api Comment content:text commentable:references
|
20
|
+
→ Comment belongs_to :commentable, polymorphic: true
|
21
|
+
|
22
|
+
rails generate propel_api Attachment name:string resource_parent:references
|
23
|
+
→ Attachment belongs_to :resource, polymorphic: true
|
24
|
+
|
25
|
+
|
26
|
+
|
27
|
+
Skip associations entirely:
|
28
|
+
rails generate propel_api Article title:string user:references --skip-associations
|
29
|
+
→ No associations generated (manual setup required)
|
30
|
+
DESC
|
31
|
+
|
32
|
+
argument :attributes, type: :array, default: [], banner: "field:type field:type"
|
33
|
+
|
34
|
+
class_option :skip_associations,
|
35
|
+
type: :boolean,
|
36
|
+
desc: "Skip automatic association generation"
|
37
|
+
|
38
|
+
class_option :skip_tenancy,
|
39
|
+
type: :boolean,
|
40
|
+
desc: "Skip multi-tenancy foundation (organization and agency)"
|
41
|
+
|
42
|
+
def create_migration
|
43
|
+
initialize_propel_api_settings
|
44
|
+
|
45
|
+
if behavior == :revoke
|
46
|
+
# Find the migration file
|
47
|
+
migration_files = Dir[File.join(destination_root, "db/migrate/*_create_#{table_name}.rb")]
|
48
|
+
|
49
|
+
if migration_files.any?
|
50
|
+
migration_file = migration_files.first
|
51
|
+
migration_version = File.basename(migration_file).split('_').first
|
52
|
+
|
53
|
+
# Check if migration was executed
|
54
|
+
if migration_was_executed?(migration_version)
|
55
|
+
say "\n" + "="*80, :red
|
56
|
+
say "⚠️ MIGRATION ALREADY EXECUTED!", :red
|
57
|
+
say "="*80, :red
|
58
|
+
say "\n📋 Migration #{migration_version} (create_#{table_name}) has been executed.", :yellow
|
59
|
+
say "\n🚨 Rails does not automatically rollback migrations for safety reasons.", :yellow
|
60
|
+
say "\n✅ To safely destroy this resource:", :green
|
61
|
+
say " 1. First rollback: rails db:rollback", :green
|
62
|
+
say " 2. Then destroy: rails destroy propel_api #{class_name}", :green
|
63
|
+
say "\n💡 Alternative approaches:", :cyan
|
64
|
+
say " • Check migration status: rails db:migrate:status"
|
65
|
+
say " • Rollback specific: rails db:migrate:down VERSION=#{migration_version}"
|
66
|
+
say "\n" + "="*80, :red
|
67
|
+
raise Thor::Error, "Please rollback the migration first, then re-run destroy command."
|
68
|
+
else
|
69
|
+
# Migration not executed, check if safe to delete
|
70
|
+
if safe_to_delete_migration?(migration_version)
|
71
|
+
say "Removing migration: #{File.basename(migration_file)}", :red
|
72
|
+
File.delete(migration_file)
|
73
|
+
else
|
74
|
+
say "\n" + "="*80, :yellow
|
75
|
+
say "⚠️ MIGRATION CLEANUP REQUIRED", :yellow
|
76
|
+
say "="*80, :yellow
|
77
|
+
say "\n📋 Found unexecuted migration: #{File.basename(migration_file)}", :cyan
|
78
|
+
say "\n🚨 Cannot auto-delete due to intervening migrations.", :yellow
|
79
|
+
say "💡 Please manually remove when safe:", :green
|
80
|
+
say " rm #{migration_file}"
|
81
|
+
say "\n" + "="*80, :yellow
|
82
|
+
end
|
83
|
+
end
|
84
|
+
else
|
85
|
+
say "No migration file found for #{table_name}", :yellow
|
86
|
+
end
|
87
|
+
else
|
88
|
+
generate :migration, "create_#{table_name}", *migration_attributes
|
89
|
+
# Post-process migration to make non-organization references nullable
|
90
|
+
update_migration_constraints
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def create_model
|
95
|
+
initialize_propel_api_settings
|
96
|
+
|
97
|
+
# Initialize relationship inferrer for templates
|
98
|
+
@relationship_inferrer = RelationshipInferrer.new(class_name, attributes, {
|
99
|
+
skip_associations: options[:skip_associations]
|
100
|
+
})
|
101
|
+
|
102
|
+
# Validate attributes if not using --all_attributes and model already exists
|
103
|
+
if File.exist?(File.join(destination_root, "app/models/#{file_name}.rb"))
|
104
|
+
validate_attributes_exist
|
105
|
+
end
|
106
|
+
|
107
|
+
case @adapter
|
108
|
+
when 'propel_facets'
|
109
|
+
template "scaffold/facet_model_template.rb.tt", "app/models/#{file_name}.rb"
|
110
|
+
when 'graphiti'
|
111
|
+
template "scaffold/graphiti_model_template.rb.tt", "app/models/#{file_name}.rb"
|
112
|
+
else
|
113
|
+
raise "Unknown adapter: #{@adapter}. Run 'rails generate propel_api:install' first."
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def create_controller
|
118
|
+
# Use shared method from Base class
|
119
|
+
create_propel_controller
|
120
|
+
end
|
121
|
+
|
122
|
+
def create_routes
|
123
|
+
# Use shared method from Base class
|
124
|
+
create_propel_routes
|
125
|
+
end
|
126
|
+
|
127
|
+
def create_tests
|
128
|
+
# Use shared method from Base class with all test types
|
129
|
+
create_propel_tests(test_types: [:model, :controller, :integration, :fixtures])
|
130
|
+
end
|
131
|
+
|
132
|
+
def create_seeds
|
133
|
+
if behavior == :revoke
|
134
|
+
# Remove seeds file when destroying
|
135
|
+
seeds_file_path = "db/seeds/#{table_name}_seeds.rb"
|
136
|
+
full_seeds_path = File.join(destination_root, seeds_file_path)
|
137
|
+
if File.exist?(full_seeds_path)
|
138
|
+
File.delete(full_seeds_path)
|
139
|
+
say "Removed seeds file: #{seeds_file_path}", :red
|
140
|
+
end
|
141
|
+
|
142
|
+
# Remove require from main seeds file
|
143
|
+
seeds_file = File.join(destination_root, "db/seeds.rb")
|
144
|
+
seeds_require = "require_relative 'seeds/#{table_name}_seeds'"
|
145
|
+
|
146
|
+
if File.exist?(seeds_file)
|
147
|
+
seeds_content = File.read(seeds_file)
|
148
|
+
if seeds_content.include?(seeds_require)
|
149
|
+
updated_content = seeds_content.dup
|
150
|
+
|
151
|
+
# Try multiple patterns to remove the require and any associated comment
|
152
|
+
patterns_to_try = [
|
153
|
+
# Pattern 1: Comment above the require
|
154
|
+
/\n# #{class_name} seeds\n#{Regexp.escape(seeds_require)}\n?/,
|
155
|
+
# Pattern 2: Comment with different casing
|
156
|
+
/\n# #{class_name.downcase} seeds\n#{Regexp.escape(seeds_require)}\n?/,
|
157
|
+
# Pattern 3: Just the require line with newlines
|
158
|
+
/\n#{Regexp.escape(seeds_require)}\n/,
|
159
|
+
# Pattern 4: Just the require line at start of line
|
160
|
+
/^#{Regexp.escape(seeds_require)}\n/,
|
161
|
+
# Pattern 5: Just the require line without trailing newline
|
162
|
+
/#{Regexp.escape(seeds_require)}/
|
163
|
+
]
|
164
|
+
|
165
|
+
removed = false
|
166
|
+
patterns_to_try.each do |pattern|
|
167
|
+
if updated_content.match?(pattern)
|
168
|
+
updated_content = updated_content.gsub(pattern, '')
|
169
|
+
removed = true
|
170
|
+
break
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
if removed
|
175
|
+
# Clean up any double newlines that might result
|
176
|
+
updated_content = updated_content.gsub(/\n\n+/, "\n\n")
|
177
|
+
# Remove leading/trailing whitespace
|
178
|
+
updated_content = updated_content.strip + "\n" if updated_content.strip.present?
|
179
|
+
|
180
|
+
File.write(seeds_file, updated_content)
|
181
|
+
say "Removed require statement from db/seeds.rb", :red
|
182
|
+
else
|
183
|
+
say "Could not automatically remove require statement from db/seeds.rb", :yellow
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
else
|
188
|
+
initialize_propel_api_settings
|
189
|
+
|
190
|
+
# Generate seeds file
|
191
|
+
template "seeds/seeds_template.rb.tt", "db/seeds/#{table_name}_seeds.rb"
|
192
|
+
|
193
|
+
# Add require to main seeds file
|
194
|
+
seeds_file = File.join(destination_root, "db/seeds.rb")
|
195
|
+
seeds_require = "require_relative 'seeds/#{table_name}_seeds'"
|
196
|
+
|
197
|
+
if File.exist?(seeds_file)
|
198
|
+
seeds_content = File.read(seeds_file)
|
199
|
+
unless seeds_content.include?(seeds_require)
|
200
|
+
append_to_file "db/seeds.rb", "\n# #{class_name} seeds\n#{seeds_require}\n"
|
201
|
+
end
|
202
|
+
else
|
203
|
+
create_file "db/seeds.rb", "#{seeds_require}\n"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
def show_completion_message
|
209
|
+
# Use shared completion message framework from Base class
|
210
|
+
generated_files = [
|
211
|
+
"📄 Model: app/models/#{file_name}.rb",
|
212
|
+
"🎮 Controller: app/controllers/#{controller_path}/#{controller_file_name}.rb",
|
213
|
+
"🗄️ Migration: db/migrate/*_create_#{table_name}.rb",
|
214
|
+
"🌱 Seeds: db/seeds/#{table_name}_seeds.rb",
|
215
|
+
"🧪 Model Tests: test/models/#{file_name}_test.rb",
|
216
|
+
"🧪 Controller Tests: test/controllers/#{controller_path}/#{controller_file_name}_test.rb",
|
217
|
+
"🧪 Integration Tests: test/integration/#{file_name}_api_test.rb",
|
218
|
+
"📋 Test Fixtures: test/fixtures/#{table_name}.yml"
|
219
|
+
]
|
220
|
+
|
221
|
+
# Add Graphiti resource if using graphiti adapter
|
222
|
+
if @adapter == 'graphiti'
|
223
|
+
generated_files << "📊 Graphiti Resource: app/resources/#{file_name}_resource.rb"
|
224
|
+
end
|
225
|
+
|
226
|
+
next_steps = [
|
227
|
+
"Run migration: rails db:migrate",
|
228
|
+
"Generate test data: rails db:seed",
|
229
|
+
"Run tests: rails test test/models/#{file_name}_test.rb",
|
230
|
+
"Run all tests: rails test",
|
231
|
+
"Test your API: GET #{api_route_path}"
|
232
|
+
]
|
233
|
+
|
234
|
+
if @adapter == 'propel_facets'
|
235
|
+
next_steps << "Customize facets in: app/models/#{file_name}.rb"
|
236
|
+
end
|
237
|
+
|
238
|
+
show_propel_completion_message(
|
239
|
+
resource_type: "Resource",
|
240
|
+
generated_files: generated_files,
|
241
|
+
next_steps: next_steps
|
242
|
+
)
|
243
|
+
end
|
244
|
+
|
245
|
+
private
|
246
|
+
|
247
|
+
def relationship_inferrer
|
248
|
+
@relationship_inferrer
|
249
|
+
end
|
250
|
+
|
251
|
+
# Override Base class method to enable Graphiti resource generation for main generator
|
252
|
+
def should_generate_graphiti_resource?
|
253
|
+
true
|
254
|
+
end
|
255
|
+
|
256
|
+
def route_name
|
257
|
+
file_name.pluralize
|
258
|
+
end
|
259
|
+
|
260
|
+
def migration_attributes
|
261
|
+
# Always include organization (required) and agency (optional) for multi-tenancy
|
262
|
+
# unless --skip-tenancy flag is used
|
263
|
+
if options[:skip_tenancy]
|
264
|
+
# Only include what the developer explicitly specified
|
265
|
+
attributes.map { |attr| "#{attr.name}:#{attr.type}" }
|
266
|
+
else
|
267
|
+
# Include multi-tenancy foundation by default
|
268
|
+
standard_attributes = ["organization:references", "agency:references"]
|
269
|
+
|
270
|
+
# Add user-specified attributes, but skip organization and agency if they're already specified
|
271
|
+
user_attributes = attributes.map { |attr| "#{attr.name}:#{attr.type}" }
|
272
|
+
user_attributes.reject! { |attr| attr.start_with?("organization:") || attr.start_with?("agency:") }
|
273
|
+
|
274
|
+
# Combine standard and user attributes
|
275
|
+
standard_attributes + user_attributes
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
def permitted_param_names
|
280
|
+
if options[:skip_tenancy]
|
281
|
+
# Only include what the developer explicitly specified
|
282
|
+
attributes.map do |attr|
|
283
|
+
# Convert references to foreign key names for permitted params
|
284
|
+
# e.g., organization:references becomes organization_id
|
285
|
+
if attr.type == :references
|
286
|
+
"#{attr.name}_id"
|
287
|
+
else
|
288
|
+
attr.name
|
289
|
+
end
|
290
|
+
end
|
291
|
+
else
|
292
|
+
# Always include organization_id (required) and agency_id (optional) for multi-tenancy
|
293
|
+
standard_params = ["organization_id", "agency_id"]
|
294
|
+
|
295
|
+
# Add user-specified attributes, but skip organization and agency if they're already specified
|
296
|
+
user_params = attributes.map do |attr|
|
297
|
+
# Convert references to foreign key names for permitted params
|
298
|
+
# e.g., organization:references becomes organization_id
|
299
|
+
if attr.type == :references
|
300
|
+
"#{attr.name}_id"
|
301
|
+
else
|
302
|
+
attr.name
|
303
|
+
end
|
304
|
+
end
|
305
|
+
user_params.reject! { |param| param == "organization_id" || param == "agency_id" }
|
306
|
+
|
307
|
+
# Combine standard and user params
|
308
|
+
standard_params + user_params
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
def namespaced?
|
313
|
+
false
|
314
|
+
end
|
315
|
+
|
316
|
+
def module_namespacing(&block)
|
317
|
+
yield
|
318
|
+
end
|
319
|
+
|
320
|
+
def api_namespaced?
|
321
|
+
@api_namespace.present? || @api_version.present?
|
322
|
+
end
|
323
|
+
|
324
|
+
def route_url
|
325
|
+
api_route_path
|
326
|
+
end
|
327
|
+
|
328
|
+
def orm_class
|
329
|
+
@orm_class ||= begin
|
330
|
+
# Simple ORM class mock for template compatibility
|
331
|
+
Class.new do
|
332
|
+
def self.all(class_name)
|
333
|
+
class_name.constantize.all
|
334
|
+
end
|
335
|
+
|
336
|
+
def self.build(class_name, params_method)
|
337
|
+
"#{class_name}.new(#{params_method})"
|
338
|
+
end
|
339
|
+
|
340
|
+
def self.find(class_name, finder)
|
341
|
+
"#{class_name}.find(#{finder})"
|
342
|
+
end
|
343
|
+
end.new
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
def migration_was_executed?(migration_version)
|
348
|
+
return false unless defined?(ActiveRecord::Base)
|
349
|
+
|
350
|
+
begin
|
351
|
+
# Ensure database connection exists
|
352
|
+
ActiveRecord::Base.connection
|
353
|
+
|
354
|
+
# Check if schema_migrations table exists (might not in fresh apps)
|
355
|
+
return false unless ActiveRecord::Base.connection.table_exists?('schema_migrations')
|
356
|
+
|
357
|
+
# Query for the specific migration version
|
358
|
+
result = ActiveRecord::Base.connection.execute(
|
359
|
+
"SELECT version FROM schema_migrations WHERE version = '#{migration_version}'"
|
360
|
+
)
|
361
|
+
|
362
|
+
# Check if we found the migration (different adapters return different result types)
|
363
|
+
case result
|
364
|
+
when Array
|
365
|
+
result.any?
|
366
|
+
else
|
367
|
+
result.to_a.any?
|
368
|
+
end
|
369
|
+
|
370
|
+
rescue => e
|
371
|
+
# If any error occurs (database not connected, etc.), assume safe to delete
|
372
|
+
say "Warning: Could not check migration status (#{e.message})", :yellow
|
373
|
+
false
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
def migration_timestamp
|
378
|
+
Time.current.utc.strftime("%Y%m%d%H%M%S")
|
379
|
+
end
|
380
|
+
|
381
|
+
def update_migration_constraints
|
382
|
+
# Find the most recent migration file for this table
|
383
|
+
migration_files = Dir[File.join(destination_root, "db/migrate/*_create_#{table_name}.rb")]
|
384
|
+
return unless migration_files.any?
|
385
|
+
|
386
|
+
migration_file = migration_files.sort.last
|
387
|
+
content = File.read(migration_file)
|
388
|
+
|
389
|
+
# Update references to be nullable except for organization
|
390
|
+
# organization:references should remain: null: false, foreign_key: true
|
391
|
+
# All other references should become: null: true, foreign_key: true
|
392
|
+
updated_content = content.gsub(/t\.references :(\w+), null: false, foreign_key: true/) do |match|
|
393
|
+
reference_name = $1
|
394
|
+
if reference_name == 'organization'
|
395
|
+
match # Keep organization as required (null: false)
|
396
|
+
else
|
397
|
+
"t.references :#{reference_name}, null: true, foreign_key: true"
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
# Write the updated migration back to file
|
402
|
+
File.write(migration_file, updated_content)
|
403
|
+
end
|
404
|
+
|
405
|
+
def safe_to_delete_migration?(migration_version)
|
406
|
+
# Get all migration files in the migrate directory
|
407
|
+
all_migrations = Dir[File.join(destination_root, "db/migrate/*.rb")]
|
408
|
+
|
409
|
+
# Extract just the timestamp versions from all migrations
|
410
|
+
all_versions = all_migrations.map do |file|
|
411
|
+
File.basename(file).split('_').first
|
412
|
+
end
|
413
|
+
|
414
|
+
# Check if any migrations have timestamps newer than our migration
|
415
|
+
newer_migrations = all_versions.select { |version| version > migration_version }
|
416
|
+
|
417
|
+
if newer_migrations.any?
|
418
|
+
# Check if any of the newer migrations have been executed
|
419
|
+
newer_executed = newer_migrations.any? { |version| migration_was_executed?(version) }
|
420
|
+
|
421
|
+
if newer_executed
|
422
|
+
# There are newer migrations that have been executed - unsafe to delete
|
423
|
+
return false
|
424
|
+
end
|
425
|
+
end
|
426
|
+
|
427
|
+
# Safe to delete if:
|
428
|
+
# 1. No newer migrations exist, OR
|
429
|
+
# 2. Newer migrations exist but none have been executed
|
430
|
+
true
|
431
|
+
end
|
432
|
+
end
|
433
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# PropelApi Configuration
|
2
|
+
# This file was generated by: rails generate propel_api:install
|
3
|
+
#
|
4
|
+
# These settings control the default behavior for PropelApi generators.
|
5
|
+
# You can override these defaults when running generators with command line options.
|
6
|
+
|
7
|
+
module PropelApi
|
8
|
+
class Configuration
|
9
|
+
attr_accessor :adapter, :namespace, :version
|
10
|
+
attr_reader :attribute_filter
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
# Default configuration values
|
14
|
+
@adapter = 'propel_facets' # Default serialization adapter
|
15
|
+
@namespace = 'api' # Default API namespace
|
16
|
+
@version = 'v1' # Default API version
|
17
|
+
|
18
|
+
# Initialize the configurable attribute filter
|
19
|
+
@attribute_filter = PropelApi::AttributeFilter.new
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Centralized, configurable attribute filtering service
|
24
|
+
# This is used by both templates and introspection to ensure consistency
|
25
|
+
class AttributeFilter
|
26
|
+
attr_accessor :sensitive_patterns, :excluded_patterns, :large_content_patterns
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
# Security-sensitive field patterns
|
30
|
+
@sensitive_patterns = [
|
31
|
+
/(password|digest|token|secret|key|salt|encrypted|confirmation|unlock|reset|api_key|access_token|refresh_token)/i,
|
32
|
+
/\A(ssn|social_security|credit_card|cvv|pin|tax_id)\z/i
|
33
|
+
]
|
34
|
+
|
35
|
+
# System/internal field patterns
|
36
|
+
@excluded_patterns = [
|
37
|
+
/\A(id|created_at|updated_at|deleted_at)\z/i,
|
38
|
+
/\A(password_digest|reset_password_token|confirmation_token|unlock_token|remember_token)\z/i,
|
39
|
+
/\A.*(_blob|_data|_binary)$/i
|
40
|
+
]
|
41
|
+
|
42
|
+
# Large content field patterns (usually not suitable for permitted params)
|
43
|
+
@large_content_patterns = [
|
44
|
+
/\A(description|content|body|notes|comment|bio|about|summary|text|html|markdown)\z/i
|
45
|
+
]
|
46
|
+
end
|
47
|
+
|
48
|
+
# Check if an attribute should be excluded from permitted params
|
49
|
+
def exclude_from_permitted_params?(attribute_name, attribute_type = nil)
|
50
|
+
attr_str = attribute_name.to_s
|
51
|
+
|
52
|
+
# Check all pattern categories
|
53
|
+
sensitive_patterns.any? { |pattern| pattern.match?(attr_str) } ||
|
54
|
+
excluded_patterns.any? { |pattern| pattern.match?(attr_str) } ||
|
55
|
+
large_content_patterns.any? { |pattern| pattern.match?(attr_str) } ||
|
56
|
+
(attribute_type == :binary)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Check if an attribute should be included in short facets (for templates)
|
60
|
+
def include_in_short_facet?(attribute_name, attribute_type)
|
61
|
+
return false if exclude_from_permitted_params?(attribute_name, attribute_type)
|
62
|
+
|
63
|
+
attr_str = attribute_name.to_s
|
64
|
+
|
65
|
+
# Include common identifying fields
|
66
|
+
identifying_fields = %w[name title label slug username email status state active published visible enabled]
|
67
|
+
simple_types = [:string, :integer, :boolean, :decimal, :float]
|
68
|
+
|
69
|
+
# Must be identifying field OR (simple type AND short name)
|
70
|
+
identifying_fields.include?(attr_str) ||
|
71
|
+
(simple_types.include?(attribute_type) && attr_str.length < 20)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Check if an attribute should be included in details facets (for templates)
|
75
|
+
def include_in_details_facet?(attribute_name, attribute_type)
|
76
|
+
!exclude_from_permitted_params?(attribute_name, attribute_type)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Helper methods for easy customization
|
80
|
+
def add_sensitive_pattern(pattern)
|
81
|
+
@sensitive_patterns << pattern
|
82
|
+
end
|
83
|
+
|
84
|
+
def add_excluded_pattern(pattern)
|
85
|
+
@excluded_patterns << pattern
|
86
|
+
end
|
87
|
+
|
88
|
+
def add_large_content_pattern(pattern)
|
89
|
+
@large_content_patterns << pattern
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get a summary of what's being filtered (for debugging/documentation)
|
93
|
+
def filtering_summary
|
94
|
+
{
|
95
|
+
sensitive_patterns: @sensitive_patterns.map(&:source),
|
96
|
+
excluded_patterns: @excluded_patterns.map(&:source),
|
97
|
+
large_content_patterns: @large_content_patterns.map(&:source)
|
98
|
+
}
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Class methods for configuration access
|
103
|
+
class << self
|
104
|
+
def configuration
|
105
|
+
@configuration ||= Configuration.new
|
106
|
+
end
|
107
|
+
|
108
|
+
def configure
|
109
|
+
yield(configuration)
|
110
|
+
end
|
111
|
+
|
112
|
+
def reset_configuration!
|
113
|
+
@configuration = Configuration.new
|
114
|
+
end
|
115
|
+
|
116
|
+
# Convenience method for accessing the filter
|
117
|
+
def attribute_filter
|
118
|
+
configuration.attribute_filter
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Configure PropelApi with the options selected during installation
|
124
|
+
PropelApi.configure do |config|
|
125
|
+
# Serialization adapter: 'propel_facets' or 'graphiti'
|
126
|
+
config.adapter = '<%= @adapter %>'
|
127
|
+
|
128
|
+
# API namespace: 'api', 'admin_api', etc. or nil for no namespace
|
129
|
+
config.namespace = <%= @api_namespace ? "'#{@api_namespace}'" : 'nil' %>
|
130
|
+
|
131
|
+
# API version: 'v1', 'v2', etc. or nil for no versioning
|
132
|
+
config.version = <%= @api_version ? "'#{@api_version}'" : 'nil' %>
|
133
|
+
|
134
|
+
# Customize attribute filtering for your project's naming conventions
|
135
|
+
# Examples:
|
136
|
+
#
|
137
|
+
# Add custom sensitive patterns:
|
138
|
+
# config.attribute_filter.add_sensitive_pattern(/private_.*/)
|
139
|
+
# config.attribute_filter.add_sensitive_pattern(/\A.*_secret\z/i)
|
140
|
+
#
|
141
|
+
# Add custom excluded patterns:
|
142
|
+
# config.attribute_filter.add_excluded_pattern(/\A(audit_|legacy_).*/)
|
143
|
+
#
|
144
|
+
# Add custom large content patterns:
|
145
|
+
# config.attribute_filter.add_large_content_pattern(/\A.*_(text|html|json)\z/i)
|
146
|
+
#
|
147
|
+
# View current filtering rules:
|
148
|
+
# puts PropelApi.attribute_filter.filtering_summary
|
149
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
class <%= api_controller_class_name %> < ApplicationController
|
2
|
+
include Graphiti::Rails
|
3
|
+
include Graphiti::Responders
|
4
|
+
include PropelAuthentication
|
5
|
+
|
6
|
+
before_action :authenticate_user
|
7
|
+
|
8
|
+
# Graphiti handles CRUD operations through resources automatically
|
9
|
+
# Individual controllers should define their resource class like:
|
10
|
+
# class UsersController < <%= api_controller_class_name %>
|
11
|
+
# self.resource = UserResource
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# Resources should be created using:
|
15
|
+
# rails generate graphiti:resource User
|
16
|
+
|
17
|
+
def index
|
18
|
+
resources = resource.all(params)
|
19
|
+
respond_with(resources)
|
20
|
+
end
|
21
|
+
|
22
|
+
def show
|
23
|
+
resource_instance = resource.find(params)
|
24
|
+
respond_with(resource_instance)
|
25
|
+
end
|
26
|
+
|
27
|
+
def create
|
28
|
+
resource_instance = resource.build(params)
|
29
|
+
|
30
|
+
if resource_instance.save
|
31
|
+
respond_with(resource_instance, status: :created)
|
32
|
+
else
|
33
|
+
respond_with(resource_instance.errors, status: :unprocessable_entity)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def update
|
38
|
+
resource_instance = resource.find(params)
|
39
|
+
|
40
|
+
if resource_instance.update_attributes
|
41
|
+
respond_with(resource_instance)
|
42
|
+
else
|
43
|
+
respond_with(resource_instance.errors, status: :unprocessable_entity)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def destroy
|
48
|
+
resource_instance = resource.find(params)
|
49
|
+
resource_instance.destroy
|
50
|
+
|
51
|
+
respond_with(resource_instance)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# Override in subclasses to specify the Graphiti resource class
|
57
|
+
# Example:
|
58
|
+
# def resource
|
59
|
+
# UserResource
|
60
|
+
# end
|
61
|
+
def resource
|
62
|
+
@resource ||= infer_resource_class.new
|
63
|
+
end
|
64
|
+
|
65
|
+
# Auto-infer resource class from controller name
|
66
|
+
# e.g., UsersController -> UserResource
|
67
|
+
def infer_resource_class
|
68
|
+
resource_class_name = "#{controller_name.classify}Resource"
|
69
|
+
resource_class_name.constantize
|
70
|
+
|
71
|
+
raise "#{resource_class_name} not found. Please create it using: rails generate graphiti:resource #{controller_name.classify}"
|
72
|
+
end
|
73
|
+
|
74
|
+
# Graphiti handles parameter filtering through resources automatically
|
75
|
+
# No need for manual strong parameters - resources define allowed attributes
|
76
|
+
# Define attributes in your resource files:
|
77
|
+
# attribute :name, :string
|
78
|
+
# attribute :email, :string
|
79
|
+
end
|