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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +226 -0
- data/README.md +42 -3
- data/lib/belt/action_router.rb +34 -19
- data/lib/belt/cli/app_detection.rb +30 -3
- data/lib/belt/cli/auth_command.rb +395 -0
- data/lib/belt/cli/console_command.rb +17 -2
- data/lib/belt/cli/deploy_command.rb +51 -17
- data/lib/belt/cli/destroy_command.rb +62 -25
- data/lib/belt/cli/doctor_command.rb +166 -22
- data/lib/belt/cli/environment_command.rb +12 -0
- data/lib/belt/cli/explain_command.rb +112 -0
- data/lib/belt/cli/frontend_command.rb +31 -10
- data/lib/belt/cli/frontend_deploy_command.rb +110 -21
- data/lib/belt/cli/frontend_env_command.rb +56 -19
- data/lib/belt/cli/frontend_env_map.rb +30 -10
- data/lib/belt/cli/frontend_registry.rb +373 -0
- data/lib/belt/cli/frontend_setup_command.rb +71 -5
- data/lib/belt/cli/generate_command.rb +61 -42
- data/lib/belt/cli/index_command.rb +192 -0
- data/lib/belt/cli/lambda_config_command.rb +9 -3
- data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
- data/lib/belt/cli/routes_command/route_inference.rb +4 -3
- data/lib/belt/cli/routes_command.rb +31 -19
- data/lib/belt/cli/server_command.rb +28 -15
- data/lib/belt/cli/tables_command.rb +3 -1
- data/lib/belt/cli/terraform_command.rb +9 -0
- data/lib/belt/cli/views_command.rb +15 -15
- data/lib/belt/cli/zip_artifact_builder.rb +199 -0
- data/lib/belt/cli.rb +17 -9
- data/lib/belt/docs/backups.md +101 -0
- data/lib/belt/docs/console.md +65 -0
- data/lib/belt/docs/controllers.md +155 -0
- data/lib/belt/docs/deployment.md +166 -0
- data/lib/belt/docs/frontend.md +103 -0
- data/lib/belt/docs/generators.md +137 -0
- data/lib/belt/docs/lambda_handler.md +105 -0
- data/lib/belt/docs/models.md +160 -0
- data/lib/belt/docs/observability.md +94 -0
- data/lib/belt/docs/plugins.md +94 -0
- data/lib/belt/docs/routing.md +138 -0
- data/lib/belt/docs/structure.md +96 -0
- data/lib/belt/inflector.rb +8 -0
- data/lib/belt/route_dsl.rb +318 -100
- data/lib/belt/version.rb +1 -1
- data/lib/templates/frontend/react/env.yml.example +2 -2
- data/lib/templates/generate/auth/cognito.tf.erb +71 -0
- data/lib/templates/generate/auth/cognito_outputs.tf.erb +19 -0
- data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +55 -0
- data/lib/templates/generate/auth/frontend/Login.jsx +79 -0
- data/lib/templates/generate/auth/frontend/ProtectedRoute.jsx +9 -0
- data/lib/templates/generate/auth/frontend/SignUp.jsx +58 -0
- data/lib/templates/generate/auth/frontend/apiClient.js +34 -0
- data/lib/templates/generate/auth/frontend/auth.css +157 -0
- data/lib/templates/generate/auth/frontend/auth.js +121 -0
- data/lib/templates/generate/model.rb.erb +4 -6
- data/lib/templates/module/frontend.tf.erb +49 -43
- data/lib/templates/module/outputs.tf.erb +1 -1
- data/lib/templates/new_app/AGENTS.md.erb +170 -20
- data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
- data/lib/templates/new_app/config/routes.rb.erb +8 -3
- data/lib/templates/new_app/lambda/api.rb.erb +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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?(
|
|
459
|
-
|
|
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
|
-
|
|
465
|
-
if content.match?(
|
|
466
|
-
content.sub!(
|
|
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}
|
|
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
|
-
|
|
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'))
|
|
149
|
-
|
|
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)
|
|
18
|
-
#
|
|
19
|
-
|
|
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.
|