belt 0.2.17 → 0.3.14

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +226 -0
  3. data/README.md +42 -3
  4. data/lib/belt/action_router.rb +34 -19
  5. data/lib/belt/cli/app_detection.rb +30 -3
  6. data/lib/belt/cli/auth_command.rb +395 -0
  7. data/lib/belt/cli/console_command.rb +17 -2
  8. data/lib/belt/cli/deploy_command.rb +51 -17
  9. data/lib/belt/cli/destroy_command.rb +62 -25
  10. data/lib/belt/cli/doctor_command.rb +166 -22
  11. data/lib/belt/cli/environment_command.rb +12 -0
  12. data/lib/belt/cli/explain_command.rb +112 -0
  13. data/lib/belt/cli/frontend_command.rb +31 -10
  14. data/lib/belt/cli/frontend_deploy_command.rb +110 -21
  15. data/lib/belt/cli/frontend_env_command.rb +56 -19
  16. data/lib/belt/cli/frontend_env_map.rb +30 -10
  17. data/lib/belt/cli/frontend_registry.rb +373 -0
  18. data/lib/belt/cli/frontend_setup_command.rb +71 -5
  19. data/lib/belt/cli/generate_command.rb +61 -42
  20. data/lib/belt/cli/index_command.rb +192 -0
  21. data/lib/belt/cli/lambda_config_command.rb +9 -3
  22. data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
  23. data/lib/belt/cli/routes_command/route_inference.rb +4 -3
  24. data/lib/belt/cli/routes_command.rb +31 -19
  25. data/lib/belt/cli/server_command.rb +28 -15
  26. data/lib/belt/cli/tables_command.rb +3 -1
  27. data/lib/belt/cli/terraform_command.rb +9 -0
  28. data/lib/belt/cli/views_command.rb +15 -15
  29. data/lib/belt/cli/zip_artifact_builder.rb +199 -0
  30. data/lib/belt/cli.rb +17 -9
  31. data/lib/belt/docs/backups.md +101 -0
  32. data/lib/belt/docs/console.md +65 -0
  33. data/lib/belt/docs/controllers.md +155 -0
  34. data/lib/belt/docs/deployment.md +166 -0
  35. data/lib/belt/docs/frontend.md +103 -0
  36. data/lib/belt/docs/generators.md +137 -0
  37. data/lib/belt/docs/lambda_handler.md +105 -0
  38. data/lib/belt/docs/models.md +160 -0
  39. data/lib/belt/docs/observability.md +94 -0
  40. data/lib/belt/docs/plugins.md +94 -0
  41. data/lib/belt/docs/routing.md +138 -0
  42. data/lib/belt/docs/structure.md +96 -0
  43. data/lib/belt/inflector.rb +8 -0
  44. data/lib/belt/route_dsl.rb +318 -100
  45. data/lib/belt/version.rb +1 -1
  46. data/lib/templates/frontend/react/env.yml.example +2 -2
  47. data/lib/templates/generate/auth/cognito.tf.erb +71 -0
  48. data/lib/templates/generate/auth/cognito_outputs.tf.erb +19 -0
  49. data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +55 -0
  50. data/lib/templates/generate/auth/frontend/Login.jsx +79 -0
  51. data/lib/templates/generate/auth/frontend/ProtectedRoute.jsx +9 -0
  52. data/lib/templates/generate/auth/frontend/SignUp.jsx +58 -0
  53. data/lib/templates/generate/auth/frontend/apiClient.js +34 -0
  54. data/lib/templates/generate/auth/frontend/auth.css +157 -0
  55. data/lib/templates/generate/auth/frontend/auth.js +121 -0
  56. data/lib/templates/generate/model.rb.erb +4 -6
  57. data/lib/templates/module/frontend.tf.erb +49 -43
  58. data/lib/templates/module/outputs.tf.erb +1 -1
  59. data/lib/templates/new_app/AGENTS.md.erb +170 -20
  60. data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
  61. data/lib/templates/new_app/config/routes.rb.erb +8 -3
  62. data/lib/templates/new_app/lambda/api.rb.erb +1 -1
  63. metadata +56 -1
@@ -3,8 +3,10 @@
3
3
  require 'fileutils'
4
4
  require 'erb'
5
5
  require_relative 'app_detection'
6
+ require_relative 'auth_command'
6
7
  require_relative 'environment_command'
7
8
  require_relative 'frontend_command'
9
+ require_relative 'frontend_registry'
8
10
  require_relative 'tables_command'
9
11
  require_relative 'views_command'
10
12
  require_relative 'generator_registry'
@@ -14,7 +16,8 @@ module Belt
14
16
  module CLI
15
17
  class GenerateCommand
16
18
  TEMPLATE_DIR = File.expand_path('../../templates/generate', __dir__)
17
- GENERATORS = %w[scaffold resource model controller environment frontend views].freeze
19
+
20
+ GENERATORS = %w[scaffold resource model controller environment frontend views index auth].freeze
18
21
 
19
22
  include AppDetection
20
23
 
@@ -26,12 +29,14 @@ module Belt
26
29
  usage: 'belt generate scaffold <name> [field:type ...] [options]',
27
30
  options: [
28
31
  ['--skip-views', 'Skip generating frontend view pages'],
32
+ ['--frontend NAME', 'Target frontend when several exist'],
29
33
  ['--force, -f', 'Overwrite existing resource files (skip collision check)']
30
34
  ],
31
35
  examples: [
32
36
  ['belt g scaffold post title body:text'],
33
37
  ['belt g scaffold comment post:references body:text'],
34
38
  ['belt g scaffold task --skip-views'],
39
+ ['belt g scaffold bag --frontend ops'],
35
40
  ['belt g scaffold post title body:text --force']
36
41
  ],
37
42
  notes: <<~NOTES
@@ -42,7 +47,7 @@ module Belt
42
47
  config/contracts.rb API response contract added
43
48
  lambda/lib/routes/<app>_routes.rb Route manifest updated
44
49
  infrastructure/modules/app/dynamodb.tf DynamoDB table generated
45
- frontend/src/pages/<names>/ React pages (if frontend exists)
50
+ <frontend>/src/pages/<names>/ React pages (if a frontend exists)
46
51
 
47
52
  Nested Resources:
48
53
  Use `<parent>:references` to create a nested resource. This will:
@@ -126,6 +131,10 @@ module Belt
126
131
 
127
132
  return Belt::CLI::ViewsCommand.run(args) if generator == 'views'
128
133
 
134
+ return Belt::CLI::IndexCommand.run(['add'] + args) if generator == 'index'
135
+
136
+ return Belt::CLI::AuthCommand.run(args) if generator == 'auth'
137
+
129
138
  name = args.shift
130
139
  if name.nil? || name.empty?
131
140
  print_generator_help(generator)
@@ -136,8 +145,10 @@ module Belt
136
145
 
137
146
  force = args.delete('--force') || args.delete('-f')
138
147
  skip_views = args.delete('--skip-views')
148
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
139
149
  fields = args.map { |arg| parse_field(arg) }
140
- new(generator, name, fields, skip_views: skip_views, force: force).generate
150
+ new(generator, name, fields, skip_views: skip_views, force: force,
151
+ frontend_name: frontend_name).generate
141
152
  end
142
153
 
143
154
  def self.parse_field(arg)
@@ -192,8 +203,9 @@ module Belt
192
203
  scaffold Generate model, controller, routes, schema, and views (full REST resource)
193
204
  model Generate an ActiveItem model
194
205
  controller Generate a controller
206
+ auth Generate Cognito user pool infrastructure
195
207
  environment Create a new deployment environment
196
- frontend Scaffold a frontend app (react, vue, svelte)
208
+ frontend Scaffold a frontend app (react, vue, svelte; --name / --path for extras)
197
209
  views Generate React pages for a resource
198
210
 
199
211
  Aliases:
@@ -221,7 +233,9 @@ module Belt
221
233
  belt g controller comments
222
234
  belt g environment staging
223
235
  belt g frontend react
236
+ belt g frontend react --name ops --path ops-app
224
237
  belt g views post title body:text
238
+ belt g views bag --frontend ops
225
239
 
226
240
  Run 'belt generate <generator> --help' for detailed help on a specific generator.
227
241
  HELP
@@ -264,12 +278,14 @@ module Belt
264
278
  puts "\n#{info[:notes]}" if info[:notes]
265
279
  end
266
280
 
267
- def initialize(generator, name, fields, skip_views: false, force: false)
281
+ # rubocop:disable Metrics/ParameterLists -- keyword options for generator flags
282
+ def initialize(generator, name, fields, skip_views: false, force: false, frontend_name: nil)
268
283
  @generator = generator
269
284
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')
270
285
  @fields = fields
271
286
  @skip_views = skip_views
272
287
  @force = force
288
+ @frontend_name = frontend_name
273
289
  @app_name = detect_namespace
274
290
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
275
291
  @singular_name = Belt::Inflector.singularize(@name)
@@ -277,6 +293,7 @@ module Belt
277
293
  @class_name = Belt::Inflector.classify(@singular_name)
278
294
  @references, @regular_fields = @fields.partition { |f| f[:type] == 'references' }
279
295
  end
296
+ # rubocop:enable Metrics/ParameterLists
280
297
 
281
298
  def generate
282
299
  case @generator
@@ -286,7 +303,9 @@ module Belt
286
303
  when 'model'
287
304
  check_model_collision! unless @force
288
305
  generate_model_standalone
289
- when 'controller' then generate_controller
306
+ when 'controller'
307
+ generate_controller
308
+ inject_routes
290
309
  end
291
310
  end
292
311
 
@@ -347,18 +366,6 @@ module Belt
347
366
  inject_parent_associations
348
367
  sync_tables
349
368
  generate_views_if_frontend
350
- puts "\n✓ Scaffold '#{@singular_name}' generated!"
351
- puts "\nFiles created/updated:"
352
- puts " lambda/models/#{@singular_name}.rb"
353
- puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
354
- puts " #{find_routes_file_path || 'config/routes.rb'} (updated)"
355
- puts " #{find_contracts_file_path || 'config/contracts.rb'} (updated)"
356
- puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
357
- puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
358
- @references.each do |ref|
359
- parent_model_path = "lambda/models/#{ref[:referenced_model]}.rb"
360
- puts " #{parent_model_path} (updated — added has_many)" if File.exist?(parent_model_path)
361
- end
362
369
  end
363
370
 
364
371
  def generate_model_standalone
@@ -382,25 +389,26 @@ module Belt
382
389
 
383
390
  def inject_routes
384
391
  routes_file = find_routes_file_path
385
- return unless routes_file && File.exist?(routes_file)
386
392
 
387
- content = File.read(routes_file)
388
- tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
393
+ if routes_file && File.exist?(routes_file)
394
+ content = File.read(routes_file)
395
+ tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
389
396
 
390
- # If this resource has a reference to a parent that already has routes, nest it
391
- parent_ref = @references.find do |ref|
392
- parent_resource = Belt::Inflector.pluralize(ref[:referenced_model])
393
- content.match?(/resources :#{Regexp.escape(parent_resource)}\b/)
394
- end
397
+ # If this resource has a reference to a parent that already has routes, nest it
398
+ parent_ref = @references.find do |ref|
399
+ parent_resource = Belt::Inflector.pluralize(ref[:referenced_model])
400
+ content.match?(/resources :#{Regexp.escape(parent_resource)}\b/)
401
+ end
395
402
 
396
- if parent_ref
397
- inject_nested_route(content, routes_file, parent_ref, tables_arg)
403
+ if parent_ref
404
+ inject_nested_route(content, routes_file, parent_ref, tables_arg)
405
+ else
406
+ inject_top_level_route(content, routes_file, tables_arg)
407
+ end
398
408
  else
399
- inject_top_level_route(content, routes_file, tables_arg)
409
+ # Legacy projects without config/routes.rb — update the manifest directly
410
+ inject_route_manifest
400
411
  end
401
-
402
- # Also update route manifest
403
- inject_route_manifest
404
412
  end
405
413
 
406
414
  def inject_nested_route(content, routes_file, parent_ref, tables_arg)
@@ -452,18 +460,25 @@ module Belt
452
460
  elsif content.include?('# resources :posts')
453
461
  content.sub!('# resources :posts', resource_line)
454
462
  else
455
- # Find the target namespace block and insert before its closing `end`
463
+ # Find the target gateway (or legacy namespace) block and insert before its closing `end`
464
+ gateway_pattern = /^(\s*)gateway :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
456
465
  namespace_pattern = /^(\s*)namespace :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
457
466
 
458
- if content.match?(namespace_pattern)
459
- content.sub!(namespace_pattern) do |match|
467
+ target_pattern = if content.match?(gateway_pattern)
468
+ gateway_pattern
469
+ elsif content.match?(namespace_pattern)
470
+ namespace_pattern
471
+ end
472
+
473
+ if target_pattern
474
+ content.sub!(target_pattern) do |match|
460
475
  indent = ::Regexp.last_match(1)
461
476
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
462
477
  end
463
478
  else
464
- single_ns_pattern = /^(\s*)namespace :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
465
- if content.match?(single_ns_pattern)
466
- content.sub!(single_ns_pattern) do |match|
479
+ single_gw_pattern = /^(\s*)(?:gateway|namespace) :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
480
+ if content.match?(single_gw_pattern)
481
+ content.sub!(single_gw_pattern) do |match|
467
482
  indent = ::Regexp.last_match(1)
468
483
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
469
484
  end
@@ -592,8 +607,7 @@ module Belt
592
607
  next unless File.exist?(parent_model_path)
593
608
 
594
609
  content = File.read(parent_model_path)
595
- has_many_line = " has_many :#{@resource_name}, foreign_key: '#{ref[:referenced_model]}_id', " \
596
- "index: '#{Belt::Inflector.classify(ref[:referenced_model])}Index'"
610
+ has_many_line = " has_many :#{@resource_name}"
597
611
 
598
612
  next if content.include?("has_many :#{@resource_name}")
599
613
 
@@ -625,10 +639,15 @@ module Belt
625
639
  end
626
640
 
627
641
  def generate_views_if_frontend
628
- return unless Dir.exist?('frontend/src')
629
642
  return if @skip_views
630
643
 
631
- Belt::CLI::ViewsCommand.new(@name, @fields, force: @force).generate
644
+ registry = FrontendRegistry.new
645
+ return if registry.empty?
646
+
647
+ frontend = registry.resolve!(@frontend_name)
648
+ return unless Dir.exist?(frontend.src_dir)
649
+
650
+ Belt::CLI::ViewsCommand.new(@name, @fields, force: @force, quiet: true, frontend: frontend).generate
632
651
  end
633
652
  end
634
653
  end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tables_command'
4
+ require_relative '../inflector'
5
+
6
+ module Belt
7
+ module CLI
8
+ class IndexCommand
9
+ MODULE_DIR = 'infrastructure/modules/app'
10
+ DYNAMODB_TF = File.join(MODULE_DIR, 'dynamodb.tf')
11
+
12
+ def self.run(args)
13
+ action = args.shift
14
+
15
+ case action
16
+ when 'add', nil
17
+ add(args)
18
+ when 'remove', 'rm'
19
+ remove(args)
20
+ when '--help', '-h'
21
+ puts usage
22
+ else
23
+ # Treat first arg as table name if no subcommand
24
+ add([action] + args)
25
+ end
26
+ end
27
+
28
+ def self.usage
29
+ <<~USAGE
30
+ Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]
31
+ belt destroy index <table> <IndexName>
32
+
33
+ Add or remove a Global Secondary Index (GSI) from dynamodb.tf.
34
+
35
+ Examples:
36
+ belt generate index messages ConversationIndex --partition-key conversation_id
37
+ belt generate index messages RecentByUserIndex --partition-key user_id --sort-key created_at
38
+ belt destroy index messages ConversationIndex
39
+
40
+ Note: After modifying indexes, run `belt deploy` to apply changes to AWS.
41
+ Adding a GSI to an existing table takes ~5-10 minutes (AWS limitation).
42
+ USAGE
43
+ end
44
+
45
+ def self.add(args)
46
+ table, index_name, partition_key, sort_key = parse_add_args(args)
47
+ new(table, index_name, partition_key: partition_key, sort_key: sort_key).add
48
+ end
49
+
50
+ def self.remove(args)
51
+ table = args.shift
52
+ index_name = args.shift
53
+
54
+ if table.nil? || index_name.nil?
55
+ abort "Usage: belt destroy index <table> <IndexName>\n\n" \
56
+ 'Example: belt destroy index messages ConversationIndex'
57
+ end
58
+
59
+ new(table, index_name).remove
60
+ end
61
+
62
+ def self.parse_add_args(args)
63
+ table = args.shift
64
+ index_name = args.shift
65
+ partition_key = nil
66
+ sort_key = nil
67
+
68
+ i = 0
69
+ while i < args.length
70
+ case args[i]
71
+ when '--partition-key', '-p'
72
+ i += 1
73
+ partition_key = args[i]
74
+ when '--sort-key', '-s'
75
+ i += 1
76
+ sort_key = args[i]
77
+ end
78
+ i += 1
79
+ end
80
+
81
+ if table.nil? || index_name.nil? || partition_key.nil?
82
+ abort "Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]\n\n" \
83
+ 'Example: belt generate index messages ConversationIndex --partition-key conversation_id'
84
+ end
85
+
86
+ [table, index_name, partition_key, sort_key]
87
+ end
88
+
89
+ def initialize(table, index_name, partition_key: nil, sort_key: nil)
90
+ @table = table
91
+ @index_name = index_name
92
+ @partition_key = partition_key
93
+ @sort_key = sort_key
94
+ end
95
+
96
+ def add
97
+ validate_tf_exists!
98
+
99
+ content = File.read(DYNAMODB_TF)
100
+ table_resource = "aws_dynamodb_table\" \"#{@table}\""
101
+
102
+ unless content.include?(table_resource)
103
+ abort "Error: Table '#{@table}' not found in #{DYNAMODB_TF}.\n" \
104
+ 'Run `belt setup tables` first to generate the table.'
105
+ end
106
+
107
+ if content.include?("name = \"#{@index_name}\"")
108
+ puts " skip #{@index_name} (already exists on #{@table})"
109
+ return
110
+ end
111
+
112
+ dynamo_pk = Belt::Inflector.camelize_lower(@partition_key)
113
+ dynamo_sk = @sort_key ? Belt::Inflector.camelize_lower(@sort_key) : nil
114
+
115
+ # Build the GSI block
116
+ gsi_block = build_gsi_block(dynamo_pk, dynamo_sk)
117
+
118
+ # Build attribute blocks for new keys
119
+ attr_blocks = build_attribute_blocks(content, dynamo_pk, dynamo_sk)
120
+
121
+ # Insert into the table resource
122
+ insert_gsi(content, gsi_block, attr_blocks)
123
+
124
+ puts " create #{@index_name} on #{@table} (partition: #{dynamo_pk}#{", sort: #{dynamo_sk}" if dynamo_sk})"
125
+ puts "\n Run `belt deploy` to apply. Adding a GSI to an existing table takes ~5-10 min."
126
+ end
127
+
128
+ def remove
129
+ validate_tf_exists!
130
+
131
+ content = File.read(DYNAMODB_TF)
132
+
133
+ unless content.include?("name = \"#{@index_name}\"")
134
+ abort "Error: Index '#{@index_name}' not found in #{DYNAMODB_TF}."
135
+ end
136
+
137
+ # Remove the GSI block
138
+ content.sub!(/\n\s*global_secondary_index \{\n\s*name\s*=\s*"#{Regexp.escape(@index_name)}".*?\n\s*\}/m, '')
139
+
140
+ File.write(DYNAMODB_TF, content)
141
+ puts " remove #{@index_name} from #{@table}"
142
+ puts "\n Run `belt deploy` to apply."
143
+ end
144
+
145
+ private
146
+
147
+ def validate_tf_exists!
148
+ return if File.exist?(DYNAMODB_TF)
149
+
150
+ abort "Error: #{DYNAMODB_TF} not found.\nRun `belt setup tables` first."
151
+ end
152
+
153
+ def build_gsi_block(dynamo_pk, dynamo_sk)
154
+ lines = []
155
+ lines << ' global_secondary_index {'
156
+ lines << " name = \"#{@index_name}\""
157
+ lines << " hash_key = \"#{dynamo_pk}\""
158
+ lines << " range_key = \"#{dynamo_sk}\"" if dynamo_sk
159
+ lines << ' projection_type = "ALL"'
160
+ lines << ' }'
161
+ lines.join("\n")
162
+ end
163
+
164
+ def build_attribute_blocks(content, dynamo_pk, dynamo_sk)
165
+ blocks = []
166
+ [dynamo_pk, dynamo_sk].compact.each do |key|
167
+ next if content.include?("name = \"#{key}\"")
168
+
169
+ blocks << "\n attribute {\n name = \"#{key}\"\n type = \"S\"\n }"
170
+ end
171
+ blocks.join
172
+ end
173
+
174
+ def insert_gsi(content, gsi_block, attr_blocks)
175
+ # Find the table's resource block and insert before point_in_time_recovery
176
+ table_pattern = /resource "aws_dynamodb_table" "#{Regexp.escape(@table)}" \{.*?point_in_time_recovery/m
177
+
178
+ content.sub!(table_pattern) do |match|
179
+ # Insert attributes after last existing attribute block
180
+ unless attr_blocks.empty?
181
+ match.sub!(/( attribute \{.*?\n \})(?!.*attribute)/m) { |attr_match| "#{attr_match}#{attr_blocks}" }
182
+ end
183
+
184
+ # Insert GSI before point_in_time_recovery
185
+ match.sub('point_in_time_recovery', "#{gsi_block}\n\n point_in_time_recovery")
186
+ end
187
+
188
+ File.write(DYNAMODB_TF, content)
189
+ end
190
+ end
191
+ end
192
+ end
@@ -34,7 +34,7 @@ module Belt
34
34
  SUPPORTED_KEYS = %w[
35
35
  timeout memory_size env_vars env_keys
36
36
  s3_buckets dynamodb_tables sns_triggers sqs_triggers
37
- reserved_concurrency ephemeral_storage
37
+ reserved_concurrency ephemeral_storage iam_policy_arns
38
38
  ].freeze
39
39
 
40
40
  def self.run(args)
@@ -112,6 +112,7 @@ module Belt
112
112
  sqs_triggers SQS queue triggers
113
113
  reserved_concurrency Reserved concurrency limit
114
114
  ephemeral_storage Ephemeral storage in MB (512-10240)
115
+ iam_policy_arns Additional IAM policy ARNs (supports ref())
115
116
  HELP
116
117
  end
117
118
 
@@ -145,8 +146,13 @@ module Belt
145
146
  return {} unless Dir.exist?(config_dir)
146
147
 
147
148
  configs = {}
148
- Dir.glob(File.join(config_dir, '*.yml')).each do |file|
149
- name = File.basename(file, '.yml')
149
+ files = Dir.glob(File.join(config_dir, '*.{yml,yaml}'))
150
+ # Sort so .yml comes before .yaml for the same basename (yml wins if both exist)
151
+ files.sort_by! { |f| [File.basename(f).sub(/\.ya?ml\z/, ''), f.end_with?('.yml') ? 0 : 1] }
152
+ files.each do |file|
153
+ name = File.basename(file).sub(/\.ya?ml\z/, '')
154
+ next if configs.key?(name) # .yml takes precedence
155
+
150
156
  raw = YAML.safe_load_file(file, aliases: true) || {}
151
157
  configs[name] = resolve_environment(raw, environment)
152
158
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ class RoutesCommand
6
+ # Infers request_model and response_model from contracts using naming conventions.
7
+ #
8
+ # Request model convention cascade (first match wins):
9
+ # 1. :<verb>_<gateway>_<singular_resource> (e.g. :create_customer_item)
10
+ # 2. :<verb>_<singular_resource> (e.g. :create_item)
11
+ #
12
+ # Response model convention:
13
+ # Singular of the resource name matches a `model` in contracts.
14
+ # e.g. `resources :items` → looks for `model :item` in contracts.rb
15
+ #
16
+ # Only request_model inference applies to body-accepting verbs (POST/PUT/PATCH).
17
+ # Response model inference applies to all verbs on resource routes.
18
+ # Explicit values always win — inference only fills in blanks.
19
+ module RequestModelInference
20
+ private
21
+
22
+ # Applies convention-based request_model inference to all routes.
23
+ # Requires the set of known contract names from contracts.rb.
24
+ def infer_request_models!(routes, contracts_file)
25
+ contract_names = load_contract_names(contracts_file)
26
+ return if contract_names[:request].empty? && contract_names[:response].empty?
27
+
28
+ routes.each do |route|
29
+ infer_request_model_for!(route, contract_names[:request])
30
+ infer_response_model_for!(route, contract_names[:response])
31
+ end
32
+ end
33
+
34
+ def load_contract_names(contracts_file)
35
+ empty_result = { request: Set.new, response: Set.new }
36
+ return empty_result unless contracts_file && File.exist?(contracts_file)
37
+
38
+ # Reset application state for clean contract loading
39
+ Belt.instance_variable_set(:@application, nil)
40
+ begin
41
+ eval(File.read(contracts_file), binding, contracts_file) # rubocop:disable Security/Eval
42
+ rescue StandardError => e
43
+ warn "Warning: Failed to load contracts for inference: #{e.message}"
44
+ return empty_result
45
+ end
46
+
47
+ schema = Belt.application.schema.to_h
48
+ request_names = Set.new
49
+ response_names = Set.new
50
+ (schema[:request_models] || {}).each_key { |name| request_names << name.to_s }
51
+ (schema[:response_models] || {}).each_key { |name| response_names << name.to_s }
52
+ { request: request_names, response: response_names }
53
+ end
54
+
55
+ def infer_request_model_for!(route, request_contracts)
56
+ return if request_contracts.empty?
57
+ return unless route[:request_model].to_s.empty?
58
+ return unless body_accepting_verb?(route[:verb])
59
+
60
+ inferred = infer_request_model_for_route(route, request_contracts)
61
+ route[:request_model] = inferred if inferred
62
+ end
63
+
64
+ def infer_response_model_for!(route, response_contracts)
65
+ return if response_contracts.empty?
66
+ return unless route[:response_model].to_s.empty?
67
+
68
+ inferred = infer_response_model_for_route(route, response_contracts)
69
+ route[:response_model] = inferred if inferred
70
+ end
71
+
72
+ def infer_request_model_for_route(route, contract_names)
73
+ verb_prefix = infer_verb_prefix(route[:verb], route[:action])
74
+ return nil unless verb_prefix
75
+
76
+ resource_name = extract_singular_resource(route)
77
+ return nil unless resource_name
78
+
79
+ gateway = route[:gateway]
80
+
81
+ # Cascade: gateway-scoped first, then generic
82
+ candidates = [
83
+ "#{verb_prefix}_#{gateway}_#{resource_name}",
84
+ "#{verb_prefix}_#{resource_name}"
85
+ ]
86
+
87
+ candidates.find { |candidate| contract_names.include?(candidate) }
88
+ end
89
+
90
+ def infer_response_model_for_route(route, response_contracts)
91
+ resource_name = extract_singular_resource(route)
92
+ return nil unless resource_name
93
+
94
+ # Convention: singular resource name matches a response model
95
+ return resource_name if response_contracts.include?(resource_name)
96
+
97
+ nil
98
+ end
99
+
100
+ def infer_verb_prefix(verb, action)
101
+ # Map HTTP verb + action to the contract naming prefix
102
+ case action
103
+ when 'create' then 'create'
104
+ when 'update' then 'update'
105
+ else
106
+ # For non-standard actions on body verbs, don't infer
107
+ case verb
108
+ when 'POST' then 'create'
109
+ when 'PUT', 'PATCH' then 'update'
110
+ end
111
+ end
112
+ end
113
+
114
+ def extract_singular_resource(route)
115
+ # Extract the resource name from the path
116
+ segments = route[:path].split('/').reject(&:empty?)
117
+ # Find the last non-param segment that looks like a resource
118
+ resource_segments = segments.reject { |s| s.start_with?('{', ':') }
119
+ return nil if resource_segments.empty?
120
+
121
+ resource = resource_segments.last
122
+ Belt::Inflector.singularize(resource.gsub('-', '_'))
123
+ end
124
+
125
+ def body_accepting_verb?(verb)
126
+ %w[POST PUT PATCH].include?(verb)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -14,9 +14,10 @@ module Belt
14
14
  non_param = segments.reject { |s| s.start_with?(':', '{') }
15
15
  return gateway.name if non_param.empty?
16
16
 
17
- # Nested resources (/posts/{id}/comments) and scoped resources (/admin/users):
18
- # join non-param segments → posts/comments, admin/users
19
- return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && non_param.length > 1
17
+ # Nested resources (/posts/{id}/comments): use the last non-param segment
18
+ # as the controller name (matches Rails — nesting affects URL, not controller lookup).
19
+ # Scoped resources (/admin/users) still use the full path when controller is explicitly set.
20
+ return non_param.last.gsub('-', '_') if route.resource? && non_param.length > 1
20
21
 
21
22
  # For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
22
23
  # the segment is the action name, not the controller. Use the gateway name as controller.