dscf-core 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ This generator creates a fully functional API resource for a given model using DSCF conventions.
2
+
3
+ Usage:
4
+ rails generate common NAMESPACE::ModelName [options]
5
+
6
+ Options:
7
+ --only COMPONENT1 COMPONENT2 Only generate specific components
8
+ --skip COMPONENT1 COMPONENT2 Skip generating specific components
9
+
10
+ Available components: controller, serializer, request_spec, routes, locales, model
11
+
12
+ Examples:
13
+ rails generate common Dscf::Core::User
14
+ rails generate common UserProfile
15
+ rails generate common Billing::Invoice
16
+ rails generate common Dscf::Core::Role --only serializer request_spec
17
+ rails generate common UserProfile --skip routes locales
18
+
19
+ Note: Use space-separated values for --only/--skip options.
20
+ Do not use multiple --only/--skip flags as they will override each other.
21
+
22
+ Automatic Serializer Generation:
23
+ When generating a serializer, the generator analyzes model associations and
24
+ prompts to create missing serializers for associated models. This helps ensure
25
+ complete API serialization without manual tracking of dependencies.
26
+
27
+ • Detects missing serializers for belongs_to, has_many, has_one associations
28
+ • Interactive prompts with clear feedback
29
+ • Recursion protection prevents infinite loops in circular associations
30
+ • Can be declined to generate serializers manually later
31
+
32
+ Notes:
33
+ • If the model file exists, `ransackable_attributes` and `ransackable_associations`
34
+ will be added just above any `private` section (or at the bottom of the class).
35
+ • The generator uses introspection to avoid adding insecure attributes (e.g. password fields).
36
+ • Existing route entries, ransackable methods, or locales are not duplicated.
37
+ • If a model file is missing, serializer and ransack methods are skipped.
38
+
39
+ This generator is engine-aware and respects module namespaces.
@@ -0,0 +1,579 @@
1
+ require "rails/generators"
2
+ require "rails/generators/named_base"
3
+
4
+ class CommonGenerator < Rails::Generators::NamedBase
5
+ VALID_COMPONENTS = %w[
6
+ controller
7
+ serializer
8
+ request_spec
9
+ routes
10
+ locales
11
+ model
12
+ ].freeze
13
+
14
+ source_root File.expand_path("templates", __dir__)
15
+ desc File.read(File.expand_path("USAGE", __dir__))
16
+
17
+ class_option :only, type: :array, default: [],
18
+ desc: "Only generate specific components (controller, serializer, request_spec, routes, locales, model)"
19
+ class_option :skip, type: :array, default: [],
20
+ desc: "Skip generating specific components (controller, serializer, request_spec, routes, locales, model)"
21
+ class_option :_recursive, type: :boolean, default: false,
22
+ desc: "Internal flag to prevent recursive serializer generation prompts"
23
+
24
+ def validate_generator_options
25
+ validate_options!
26
+ end
27
+
28
+ def validate_options!
29
+ invalid_only = options[:only] - VALID_COMPONENTS
30
+ invalid_skip = options[:skip] - VALID_COMPONENTS
31
+
32
+ if invalid_only.any?
33
+ raise Thor::Error,
34
+ "Invalid values for --only: #{invalid_only.join(', ')}. Allowed values: #{VALID_COMPONENTS.join(', ')}"
35
+ end
36
+
37
+ return unless invalid_skip.any?
38
+
39
+ raise Thor::Error,
40
+ "Invalid values for --skip: #{invalid_skip.join(', ')}. Allowed values: #{VALID_COMPONENTS.join(', ')}"
41
+ end
42
+
43
+ def create_controller
44
+ return unless should_generate?("controller")
45
+
46
+ template "controller.rb.erb", controller_file_path
47
+ end
48
+
49
+ def create_serializer
50
+ return unless should_generate?("serializer")
51
+ return unless model_exists?
52
+
53
+ # Only check for missing serializers when creating (not destroying) and not in recursive generation
54
+ check_and_prompt_for_missing_serializers unless behavior == :revoke || recursive_generation?
55
+
56
+ template "serializer.rb.erb", serializer_file_path
57
+ end
58
+
59
+ def create_request_spec
60
+ return unless should_generate?("request_spec")
61
+
62
+ template "request_spec.rb.erb", request_spec_file_path
63
+ end
64
+
65
+ def update_model_with_ransackable_methods
66
+ return unless should_generate?("model")
67
+ return unless model_exists?
68
+ return unless model_file_exists?
69
+
70
+ add_ransackable_methods_to_model
71
+ end
72
+
73
+ def add_routes
74
+ return unless should_generate?("routes")
75
+
76
+ route_definition = " resources :#{route_name}"
77
+
78
+ if File.exist?(routes_file_path)
79
+ # Check if route already exists
80
+ unless File.read(routes_file_path).include?("resources :#{route_name}")
81
+ # Try to inject after engine routes draw or Rails routes draw
82
+ routes_content = File.read(routes_file_path)
83
+ if routes_content.include?("Engine.routes.draw do")
84
+ inject_into_file routes_file_path, "#{route_definition}\n", after: /\.routes\.draw do\n/
85
+ elsif routes_content.include?("Rails.application.routes.draw do")
86
+ inject_into_file routes_file_path, "#{route_definition}\n", after: /Rails\.application\.routes\.draw do\n/
87
+ else
88
+ # Fallback: append to the end before the last 'end'
89
+ inject_into_file routes_file_path, "#{route_definition}\n", before: /^end\s*$/
90
+ end
91
+ end
92
+ else
93
+ create_file routes_file_path, <<~ROUTES
94
+ Rails.application.routes.draw do
95
+ #{route_definition}
96
+ end
97
+ ROUTES
98
+ end
99
+ end
100
+
101
+ def add_locale_entries
102
+ return unless should_generate?("locales")
103
+
104
+ locale_entries = generate_locale_entries
105
+ indented_entries = indent_locale_block(locale_entries, 2)
106
+
107
+ if File.exist?(locale_file_path)
108
+ model_key = model_class_name.underscore
109
+ append_to_file locale_file_path, "\n\n#{indented_entries}" unless File.read(locale_file_path).include?("#{model_key}:")
110
+ else
111
+ create_file locale_file_path, <<~LOCALE
112
+ en:
113
+ #{indented_entries}
114
+ LOCALE
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def recursive_generation?
121
+ options[:_recursive] == true
122
+ end
123
+
124
+ def should_generate?(component)
125
+ return false if options[:skip].include?(component)
126
+ return true if options[:only].empty?
127
+
128
+ options[:only].include?(component)
129
+ end
130
+
131
+ # Name parsing methods
132
+ def resource_class_name
133
+ @resource_class_name ||= name
134
+ end
135
+
136
+ def controller_class_name
137
+ "#{model_class_name.pluralize}Controller"
138
+ end
139
+
140
+ def model_class_name
141
+ if resource_class_name.include?("::")
142
+ resource_class_name.split("::").last.singularize
143
+ else
144
+ resource_class_name.singularize
145
+ end
146
+ end
147
+
148
+ def controller_name
149
+ model_class_name.pluralize.underscore
150
+ end
151
+
152
+ def route_name
153
+ controller_name
154
+ end
155
+
156
+ def factory_name
157
+ resource_class_name.underscore.tr("/", "_")
158
+ end
159
+
160
+ # File path methods
161
+ def namespace_path
162
+ if resource_class_name.include?("::")
163
+ resource_class_name.deconstantize.underscore.gsub("::", "/")
164
+ else
165
+ ""
166
+ end
167
+ end
168
+
169
+ def controller_file_path
170
+ if namespace_path.present?
171
+ "app/controllers/#{namespace_path}/#{controller_name}_controller.rb"
172
+ else
173
+ "app/controllers/#{controller_name}_controller.rb"
174
+ end
175
+ end
176
+
177
+ def serializer_file_path
178
+ if namespace_path.present?
179
+ "app/serializers/#{namespace_path}/#{model_class_name.underscore}_serializer.rb"
180
+ else
181
+ "app/serializers/#{model_class_name.underscore}_serializer.rb"
182
+ end
183
+ end
184
+
185
+ def request_spec_file_path
186
+ if namespace_path.present?
187
+ "spec/requests/#{namespace_path}/#{controller_name}_spec.rb"
188
+ else
189
+ "spec/requests/#{controller_name}_spec.rb"
190
+ end
191
+ end
192
+
193
+ def routes_file_path
194
+ "config/routes.rb"
195
+ end
196
+
197
+ def locale_file_path
198
+ "config/locales/en.yml"
199
+ end
200
+
201
+ def model_file_path
202
+ if namespace_path.present?
203
+ "app/models/#{namespace_path}/#{model_class_name.underscore}.rb"
204
+ else
205
+ "app/models/#{model_class_name.underscore}.rb"
206
+ end
207
+ end
208
+
209
+ # Model introspection methods
210
+ def model_exists?
211
+ resource_class_name.constantize
212
+ true
213
+ rescue NameError
214
+ raise Thor::Error, <<~ERROR if should_generate?("serializer") || should_generate?("model")
215
+ Model `#{resource_class_name}` not found.
216
+ Make sure it exists, or skip model-dependent components using:
217
+ --skip model serializer
218
+ ERROR
219
+
220
+ false
221
+ end
222
+
223
+ def model_file_exists?
224
+ File.exist?(model_file_path)
225
+ end
226
+
227
+ def model_class
228
+ @model_class ||= resource_class_name.constantize
229
+ end
230
+
231
+ def model_attributes
232
+ return [] unless model_exists?
233
+
234
+ excluded_columns = %w[id created_at updated_at]
235
+
236
+ model_class.column_names.reject do |column|
237
+ column.end_with?("_id") || excluded_columns.include?(column)
238
+ end
239
+ end
240
+
241
+ def model_associations
242
+ return [] unless model_exists?
243
+
244
+ model_class.reflect_on_all_associations.map do |association|
245
+ # Get the actual class name by trying to constantize it
246
+ # This handles cases where Rails infers the class name based on the association name
247
+ actual_class_name = begin
248
+ association.klass.name
249
+ rescue NameError
250
+ # If the class can't be loaded, fall back to the association's class_name
251
+ association.class_name
252
+ end
253
+
254
+ {
255
+ name: association.name,
256
+ macro: association.macro,
257
+ class_name: actual_class_name
258
+ }
259
+ end
260
+ end
261
+
262
+ # Ransackable methods
263
+ def sensitive_attributes
264
+ %w[
265
+ password password_digest password_confirmation
266
+ token api_key secret_key access_token refresh_token
267
+ salt encrypted_password reset_password_token
268
+ confirmation_token unlock_token authentication_token
269
+ otp_secret_key single_access_token perishable_token
270
+ remember_token remember_created_at current_sign_in_at
271
+ last_sign_in_at current_sign_in_ip last_sign_in_ip
272
+ failed_attempts locked_at
273
+ ]
274
+ end
275
+
276
+ def safe_ransackable_attributes
277
+ return [] unless model_exists?
278
+
279
+ all_attributes = model_class.column_names
280
+ all_attributes.reject do |attr|
281
+ sensitive_attributes.any? { |sensitive| attr.downcase.include?(sensitive.downcase) }
282
+ end
283
+ end
284
+
285
+ def safe_ransackable_associations
286
+ return [] unless model_exists?
287
+
288
+ model_associations.map { |assoc| assoc[:name].to_s }
289
+ end
290
+
291
+ def model_has_ransackable_methods?
292
+ return false unless model_file_exists?
293
+
294
+ model_content = File.read(model_file_path)
295
+
296
+ # Check for existing methods
297
+ has_attributes_method = model_content.match?(/def\s+self\.ransackable_attributes(\s*\(.*\))?\s*$/)
298
+ has_associations_method = model_content.match?(/def\s+self\.ransackable_associations(\s*\(.*\))?\s*$/)
299
+
300
+ has_attributes_method || has_associations_method
301
+ end
302
+
303
+ def add_ransackable_methods_to_model
304
+ return unless model_file_exists?
305
+ return if model_has_ransackable_methods?
306
+
307
+ model_content = File.read(model_file_path)
308
+
309
+ # Determine the appropriate indentation by looking at existing methods
310
+ indentation = detect_model_indentation(model_content)
311
+
312
+ ransackable_methods = <<~RUBY
313
+
314
+ #{indentation}def self.ransackable_attributes(auth_object = nil)
315
+ #{indentation} %w[#{safe_ransackable_attributes.join(' ')}]
316
+ #{indentation}end
317
+
318
+ #{indentation}def self.ransackable_associations(auth_object = nil)
319
+ #{indentation} %w[#{safe_ransackable_associations.join(' ')}]
320
+ #{indentation}end
321
+ RUBY
322
+
323
+ # Strategy 1: Place just before 'private' if it exists and is properly indented
324
+ if model_content.match?(/^\s*private\s*$/)
325
+ inject_into_file model_file_path, ransackable_methods, before: /^\s*private\s*$/
326
+ # Strategy 2: Place just before 'protected' if it exists (but no private)
327
+ elsif model_content.match?(/^\s*protected\s*$/)
328
+ inject_into_file model_file_path, ransackable_methods, before: /^\s*protected\s*$/
329
+ # Strategy 3: Place before the last 'end' of the class (try to find the class end)
330
+ else
331
+ # Look for the last 'end' that closes the class
332
+ inject_into_file model_file_path, ransackable_methods, before: /^#{indentation.chomp}end\s*$|^end\s*$/
333
+ end
334
+ end
335
+
336
+ def detect_model_indentation(model_content)
337
+ # Look for existing method definitions to determine indentation
338
+ method_match = model_content.match(/^(\s+)def\s+/)
339
+ if method_match
340
+ method_match[1]
341
+ else
342
+ # Fallback: look for any line that's indented within the class
343
+ line_match = model_content.match(/^(\s+)\w+/)
344
+ if line_match
345
+ line_match[1]
346
+ else
347
+ " " # Default to 2 spaces
348
+ end
349
+ end
350
+ end
351
+
352
+ def serializer_attributes
353
+ return ":id, :created_at, :updated_at" unless model_exists?
354
+
355
+ base_attrs = %w[id]
356
+ custom_attrs = model_attributes.reject { |attr| is_sensitive_attribute?(attr) }
357
+ timestamp_attrs = %w[created_at updated_at]
358
+
359
+ all_attrs = (base_attrs + custom_attrs + timestamp_attrs)
360
+ ":#{all_attrs.join(', :')}"
361
+ end
362
+
363
+ def serializer_associations
364
+ return {} unless model_exists?
365
+
366
+ grouped_associations = {}
367
+
368
+ model_associations.group_by { |assoc| assoc[:macro] }.each do |macro, associations|
369
+ grouped_associations[macro] = associations.map do |association|
370
+ # Only try to find/suggest serializers when creating (not destroying)
371
+ serializer_name = if behavior == :revoke
372
+ # For destroy mode, just use the basic serializer name without checking
373
+ if association[:class_name].include?("::")
374
+ namespace_parts = association[:class_name].split("::")
375
+ "#{namespace_parts.last}Serializer"
376
+ else
377
+ "#{association[:class_name]}Serializer"
378
+ end
379
+ else
380
+ find_or_suggest_serializer(association[:class_name])
381
+ end
382
+
383
+ {
384
+ name: association[:name],
385
+ class_name: association[:class_name],
386
+ serializer: serializer_name
387
+ }
388
+ end
389
+ end
390
+
391
+ grouped_associations
392
+ end
393
+
394
+ # Check if an attribute is sensitive and should be excluded from serializers
395
+ def is_sensitive_attribute?(attr_name)
396
+ sensitive_attributes.any? { |sensitive| attr_name.downcase.include?(sensitive.downcase) }
397
+ end
398
+
399
+ # Find existing serializer or suggest a name for one
400
+ def find_or_suggest_serializer(class_name)
401
+ # Handle namespaced classes
402
+ if class_name.include?("::")
403
+ namespace_parts = class_name.split("::")
404
+ model_name = namespace_parts.last
405
+ namespace = namespace_parts[0..-2].join("::")
406
+ suggested_name = "#{model_name}Serializer"
407
+ serializer_class_name = "#{namespace}::#{suggested_name}"
408
+ else
409
+ suggested_name = "#{class_name}Serializer"
410
+ serializer_class_name = suggested_name
411
+ end
412
+
413
+ # Check if serializer file exists (more reliable than constantize during generation)
414
+ serializer_file = serializer_file_path_for_class(class_name)
415
+ if File.exist?(serializer_file)
416
+ serializer_class_name
417
+ else
418
+ # Return suggested name, will be used in template
419
+ suggested_name
420
+ end
421
+ end
422
+
423
+ # Check for missing serializers and prompt user
424
+ def check_and_prompt_for_missing_serializers
425
+ missing_serializers = find_missing_serializers
426
+ return if missing_serializers.empty?
427
+ return unless behavior == :invoke # Only prompt on generate, not destroy
428
+
429
+ say "\n🔍 Detected associations without serializers:", :yellow
430
+ missing_serializers.each do |missing|
431
+ say " • #{missing[:association_name]} (#{missing[:class_name]}) -> #{missing[:suggested_serializer]}"
432
+ end
433
+
434
+ say "\n🛠️ Would you like to generate the missing serializers now? (y/n)", :green
435
+ if yes?("")
436
+ say "\n🚀 Generating missing serializers...\n"
437
+ generate_missing_serializers_safely(missing_serializers)
438
+ say "✅ All missing serializers have been generated.\n", :green
439
+ else
440
+ say "\n🛑 Skipping serializer generation.\n", :yellow
441
+ say "ℹ️ You can generate them later with:", :blue
442
+ missing_serializers.each do |missing|
443
+ say " rails generate common #{missing[:class_name]} --only serializer"
444
+ end
445
+ say ""
446
+ end
447
+ end
448
+
449
+ # Generate missing serializers safely without recursion
450
+ def generate_missing_serializers_safely(missing_serializers)
451
+ missing_serializers.each do |missing|
452
+ serializer_path = serializer_file_path_for_class(missing[:class_name])
453
+
454
+ if File.exist?(serializer_path)
455
+ say " exists #{serializer_path}", :blue
456
+ next
457
+ end
458
+ begin
459
+ args = [missing[:class_name]]
460
+ opts = {only: ["serializer"], _recursive: true} # Add your recursive flag if needed
461
+
462
+ generator = self.class.new(args, opts, {
463
+ behavior: :invoke,
464
+ destination_root: destination_root,
465
+ shell: shell # Uses current shell; can customize if you want silence
466
+ })
467
+
468
+ generator.invoke_all
469
+
470
+ say " ✅ Created #{missing[:suggested_serializer]}"
471
+ rescue StandardError => e
472
+ say " ❌ Failed to create #{missing[:suggested_serializer]}: #{e.message}", :red
473
+ end
474
+ end
475
+ end
476
+
477
+ # Find all missing serializers for associations
478
+ def find_missing_serializers
479
+ missing = []
480
+
481
+ serializer_associations.each_value do |associations|
482
+ associations.each do |association|
483
+ class_name = association[:class_name]
484
+
485
+ next if class_name.start_with?("ActiveStorage::")
486
+
487
+ # Check if serializer file exists
488
+ serializer_file = serializer_file_path_for_class(class_name)
489
+
490
+ next if File.exist?(serializer_file)
491
+
492
+ # Try to find the serializer
493
+ if class_name.include?("::")
494
+ namespace_parts = class_name.split("::")
495
+ model_name = namespace_parts.last
496
+ namespace = namespace_parts[0..-2].join("::")
497
+ suggested_serializer = "#{model_name}Serializer"
498
+ full_serializer_name = "#{namespace}::#{suggested_serializer}"
499
+ else
500
+ suggested_serializer = "#{class_name}Serializer"
501
+ full_serializer_name = suggested_serializer
502
+ end
503
+
504
+ missing << {
505
+ association_name: association[:name],
506
+ class_name: class_name,
507
+ suggested_serializer: suggested_serializer,
508
+ full_serializer_name: full_serializer_name
509
+ }
510
+ end
511
+ end
512
+
513
+ missing.uniq { |m| m[:class_name] }
514
+ end
515
+
516
+ # Get serializer file path for a given class name
517
+ def serializer_file_path_for_class(class_name)
518
+ if class_name.include?("::")
519
+ namespace_parts = class_name.split("::")
520
+ model_name = namespace_parts.last
521
+ namespace_path = namespace_parts[0..-2].join("/").underscore
522
+ "app/serializers/#{namespace_path}/#{model_name.underscore}_serializer.rb"
523
+ else
524
+ "app/serializers/#{class_name.underscore}_serializer.rb"
525
+ end
526
+ end
527
+
528
+ # Controller namespace methods
529
+ def controller_namespace
530
+ resource_class_name.include?("::") ? resource_class_name.deconstantize : nil
531
+ end
532
+
533
+ def controller_namespace_modules
534
+ return [] unless controller_namespace
535
+
536
+ controller_namespace.split("::")
537
+ end
538
+
539
+ # Locale generation
540
+ def generate_locale_entries
541
+ model_key = model_class_name.underscore
542
+
543
+ <<~LOCALE.chomp
544
+ #{model_key}:
545
+ success:
546
+ index: "#{model_class_name.pluralize.humanize} retrieved successfully"
547
+ show: "#{model_class_name.humanize} details retrieved successfully"
548
+ create: "#{model_class_name.humanize} created successfully"
549
+ update: "#{model_class_name.humanize} updated successfully"
550
+ errors:
551
+ index: "Failed to retrieve #{model_class_name.pluralize.humanize}"
552
+ show: "Failed to retrieve #{model_class_name.humanize} details"
553
+ create: "Failed to create #{model_class_name.humanize.downcase}"
554
+ update: "Failed to update #{model_class_name.humanize.downcase}"
555
+ LOCALE
556
+ end
557
+
558
+ def indent_locale_block(yaml_string, spaces)
559
+ indent = " " * spaces
560
+ yaml_string.lines.map { |line| "#{indent}#{line}" }.join
561
+ end
562
+
563
+ # Spec helper methods
564
+ def spec_describe_name
565
+ if namespace_path.present?
566
+ "'#{namespace_path.camelize}::#{controller_class_name}'"
567
+ else
568
+ "'#{controller_class_name}'"
569
+ end
570
+ end
571
+
572
+ def spec_url_helper
573
+ if namespace_path.present?
574
+ "#{namespace_path.underscore.tr('/', '_')}_#{controller_name}"
575
+ else
576
+ controller_name
577
+ end
578
+ end
579
+ end
@@ -0,0 +1,75 @@
1
+ <% if controller_namespace_modules.any? -%>
2
+ <% controller_namespace_modules.each do |mod| -%>
3
+ module <%= mod %>
4
+ <% end -%>
5
+ class <%= controller_class_name %> < ApplicationController
6
+ include Dscf::Core::Common
7
+
8
+ private
9
+
10
+ def model_params
11
+ params.require(:<%= model_class_name.underscore %>).permit(
12
+ # TODO: Add your permitted parameters here
13
+ # Example: :name, :email, :status
14
+ )
15
+ end
16
+
17
+ # Override to define associations to eager load
18
+ def eager_loaded_associations
19
+ []
20
+ end
21
+
22
+ # Override to define allowed columns for ordering/pagination
23
+ def allowed_order_columns
24
+ %w[id created_at updated_at]
25
+ end
26
+
27
+ # Override to define serializer includes for different actions
28
+ def default_serializer_includes
29
+ {
30
+ default: [],
31
+ index: [],
32
+ show: [],
33
+ create: [],
34
+ update: []
35
+ }
36
+ end
37
+ end
38
+ <% controller_namespace_modules.each do |mod| -%>
39
+ end
40
+ <% end -%>
41
+ <% else -%>
42
+ class <%= controller_class_name %> < ApplicationController
43
+ include Dscf::Core::Common
44
+
45
+ private
46
+
47
+ def model_params
48
+ params.require(:<%= model_class_name.underscore %>).permit(
49
+ # TODO: Add your permitted parameters here
50
+ # Example: :name, :email, :status
51
+ )
52
+ end
53
+
54
+ # Override to define associations to eager load
55
+ def eager_loaded_associations
56
+ []
57
+ end
58
+
59
+ # Override to define allowed columns for ordering/pagination
60
+ def allowed_order_columns
61
+ %w[id created_at updated_at]
62
+ end
63
+
64
+ # Override to define serializer includes for different actions
65
+ def default_serializer_includes
66
+ {
67
+ default: [],
68
+ index: [],
69
+ show: [],
70
+ create: [],
71
+ update: []
72
+ }
73
+ end
74
+ end
75
+ <% end -%>