belt 0.3.5 → 0.3.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 803700aa6a859842c684ea0dc77b455a050698f1585663346e2c0aa1c057687a
4
- data.tar.gz: b442f719e65920b93f1ba09eb841ba6498490645630576e8700d448073f21731
3
+ metadata.gz: 0be7ca644efba0ca7b1e95b076bbae96873ced55d3251e5de235208169bbcce9
4
+ data.tar.gz: d5f0b277442b1b139fb48c306b4f2c0ca13ce30d6b35637a67749b549f6a7182
5
5
  SHA512:
6
- metadata.gz: 6ebc34ade7cbbd8867330adbbe5c0ae2732719265d7afe1c080a4d9320c7d80e1dce316186f2de560bcda3df76ad8bdc4af31a2d7c3bd77d95c0dbd0c2fe41b5
7
- data.tar.gz: 43abfb9e472722ca8728558edd91f2a41a8f77f5857d992528c915a35c36951f9b30548918d11f2a6291947714abd9f823049d22e3e3907ad850b16ae5c3331c
6
+ metadata.gz: cac4e33287828876e5f248dcf3536f2e4709b2637481453e9d5015711b8a1de4bfc854edfc429060c7929a17f3ec46b17dc15127474ce9bd0312f064c23d77ee
7
+ data.tar.gz: eca9b584458ff63559fe3e4fad915be300628ec0659ae4e3d7d7cac66bba8d8d2e2eb9dd9010174f3d6bce6d7e688356e1325070f7aec0150e518348ab8be50d
data/CHANGELOG.md CHANGED
@@ -1,6 +1,40 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.3.7
4
+
5
+ ### Bug Fix
6
+
7
+ - **App name detection in worktrees**: `detect_app_name` now checks
8
+ `infrastructure/*/variables.tf` for `variable "app_name" { default = "..." }`
9
+ before falling back to the directory name. Fixes incorrect Lambda function
10
+ lookups (e.g. `belt logs`) when running from a git worktree whose directory
11
+ name doesn't match the actual app name.
12
+
13
+ ## 0.3.6
14
+
15
+ ### Enhancement
16
+
17
+ - **Per-action request_model**: `request_model` on `resources` now accepts a Hash
18
+ to specify different validation schemas per action:
19
+ ```ruby
20
+ resources :items, request_model: { create: :create_item, update: :update_item }
21
+ ```
22
+ Symbol style (uniform for all body verbs) still works unchanged.
23
+
24
+ - **Convention-based request_model inference**: When no explicit `request_model` is
25
+ set on a resource route, `belt routes` auto-discovers matching contracts from
26
+ `contracts.rb` using a naming cascade:
27
+ 1. `:<verb>_<gateway>_<singular_resource>` (e.g. `:create_customer_item`)
28
+ 2. `:<verb>_<singular_resource>` (e.g. `:create_item`)
29
+
30
+ Only applies to body-accepting verbs (POST/PUT/PATCH). Explicit `request_model:`
31
+ always takes priority. No matching contract = no validation (same as before).
32
+
33
+ - **Convention-based response_model inference**: When no explicit `response_model` is
34
+ set on a resource route, `belt routes` auto-discovers a matching response model
35
+ from `contracts.rb` using the singular resource name.
36
+ e.g. `resources :items` → looks for `model :item` → auto-wires `response_model: :item`.
37
+ Applies to all verbs. Explicit `response_model:` always takes priority.
4
38
 
5
39
  ## 0.3.5
6
40
 
@@ -17,7 +17,7 @@ module Belt
17
17
  end
18
18
 
19
19
  # Detects the application name.
20
- # Prefers terraform.tfvars app_name, then falls back to directory name.
20
+ # Priority: terraform.tfvars app_name > variables.tf default > directory name.
21
21
  def detect_app_name
22
22
  # Check any environment tfvars for the app_name
23
23
  Dir.glob('infrastructure/*/terraform.tfvars').each do |f|
@@ -26,6 +26,10 @@ module Belt
26
26
  return match[1] if match
27
27
  end
28
28
 
29
+ # Check variables.tf for a default app_name value
30
+ name = detect_app_name_from_variables_tf
31
+ return name if name
32
+
29
33
  # Fall back to directory name
30
34
  File.basename(Dir.pwd)
31
35
  end
@@ -67,6 +71,27 @@ module Belt
67
71
  def find_schema_file_path
68
72
  find_contracts_file_path
69
73
  end
74
+
75
+ private
76
+
77
+ # Parses `variable "app_name" { default = "..." }` from any environment's variables.tf.
78
+ # This handles the common pattern where app_name is a Terraform variable with a default
79
+ # rather than being set directly in terraform.tfvars.
80
+ def detect_app_name_from_variables_tf
81
+ Dir.glob('infrastructure/*/variables.tf').each do |f|
82
+ content = File.read(f)
83
+ # Match the app_name variable block and extract its default value
84
+ next unless content.match?(/variable\s+"app_name"/)
85
+
86
+ # Extract default from within the variable block
87
+ block_match = content.match(/variable\s+"app_name"\s*\{([^}]*)\}/m)
88
+ next unless block_match
89
+
90
+ default_match = block_match[1].match(/default\s*=\s*"([^"]+)"/)
91
+ return default_match[1] if default_match
92
+ end
93
+ nil
94
+ end
70
95
  end
71
96
  end
72
97
  end
@@ -321,6 +321,19 @@ module Belt
321
321
  return match[1] if match
322
322
  end
323
323
 
324
+ # Try variables.tf for a default app_name value
325
+ vars_file = File.join(@infra_dir, @env, 'variables.tf')
326
+ if File.exist?(vars_file)
327
+ content = File.read(vars_file)
328
+ if content.match?(/variable\s+"app_name"/)
329
+ block_match = content.match(/variable\s+"app_name"\s*\{([^}]*)\}/m)
330
+ if block_match
331
+ default_match = block_match[1].match(/default\s*=\s*"([^"]+)"/)
332
+ return default_match[1] if default_match
333
+ end
334
+ end
335
+ end
336
+
324
337
  # Fall back to directory name
325
338
  File.basename(@project_root)
326
339
  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
@@ -7,12 +7,14 @@ require_relative '../route_dsl'
7
7
  require_relative '../table_inference'
8
8
  require_relative 'routes_command/schema_loader'
9
9
  require_relative 'routes_command/route_inference'
10
+ require_relative 'routes_command/request_model_inference'
10
11
 
11
12
  module Belt
12
13
  module CLI
13
14
  class RoutesCommand
14
15
  include SchemaLoader
15
16
  include RouteInference
17
+ include RequestModelInference
16
18
 
17
19
  def self.run(args)
18
20
  new(args).run
@@ -33,6 +35,11 @@ module Belt
33
35
  dsl = load_routes(routes_file)
34
36
  @table_inference = TableInference.new(@options[:tables_file])
35
37
  routes = collect_routes(dsl)
38
+
39
+ # Convention-based request_model inference from contracts
40
+ contracts_file = resolve_contracts_file(routes_file)
41
+ infer_request_models!(routes, contracts_file) if contracts_file
42
+
36
43
  routes = apply_grep(routes) if @options[:grep]
37
44
 
38
45
  warn 'Warning: --output-dir has no effect without --namespace' if @options[:output_dir] && !@options[:namespace]
@@ -106,17 +106,37 @@ module Belt
106
106
  private
107
107
 
108
108
  def add_nested_resource_routes(resource_name, param_name, resource_options, actions)
109
- @gateway.send(:add_route, :get, "#{@prefix}/#{resource_name}", resource_options) if actions.include?(:index)
110
- @gateway.send(:add_route, :post, "#{@prefix}/#{resource_name}", resource_options) if actions.include?(:create)
109
+ if actions.include?(:index)
110
+ @gateway.send(:add_route, :get, "#{@prefix}/#{resource_name}",
111
+ resolve_request_model_for(resource_options, :index))
112
+ end
113
+ if actions.include?(:create)
114
+ @gateway.send(:add_route, :post, "#{@prefix}/#{resource_name}",
115
+ resolve_request_model_for(resource_options, :create))
116
+ end
111
117
  if actions.include?(:show)
112
- @gateway.send(:add_route, :get, "#{@prefix}/#{resource_name}/{#{param_name}}", resource_options)
118
+ @gateway.send(:add_route, :get, "#{@prefix}/#{resource_name}/{#{param_name}}",
119
+ resolve_request_model_for(resource_options, :show))
113
120
  end
114
121
  if actions.include?(:update)
115
- @gateway.send(:add_route, :put, "#{@prefix}/#{resource_name}/{#{param_name}}", resource_options)
122
+ @gateway.send(:add_route, :put, "#{@prefix}/#{resource_name}/{#{param_name}}",
123
+ resolve_request_model_for(resource_options, :update))
116
124
  end
117
125
  return unless actions.include?(:destroy)
118
126
 
119
- @gateway.send(:add_route, :delete, "#{@prefix}/#{resource_name}/{#{param_name}}", resource_options)
127
+ @gateway.send(:add_route, :delete, "#{@prefix}/#{resource_name}/{#{param_name}}",
128
+ resolve_request_model_for(resource_options, :destroy))
129
+ end
130
+
131
+ def resolve_request_model_for(options, action)
132
+ rm = options[:request_model]
133
+ case rm
134
+ when Hash
135
+ resolved = rm[action] || rm[action.to_s]
136
+ options.merge(request_model: resolved)
137
+ else
138
+ options
139
+ end
120
140
  end
121
141
 
122
142
  def merge_inherited_options(options)
@@ -200,11 +220,7 @@ module Belt
200
220
  resource_options = options.merge(route_type: :resources)
201
221
  actions = determine_actions(options)
202
222
 
203
- add_route(:get, "/#{resource_name}", resource_options) if actions.include?(:index)
204
- add_route(:post, "/#{resource_name}", resource_options) if actions.include?(:create)
205
- add_route(:get, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:show)
206
- add_route(:put, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:update)
207
- add_route(:delete, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:destroy)
223
+ add_resource_routes(resource_name, param_name, resource_options, actions)
208
224
 
209
225
  return unless block_given?
210
226
 
@@ -226,10 +242,22 @@ module Belt
226
242
  actions = determine_actions(options, default: %i[show update destroy])
227
243
  resource_options = options.merge(route_type: :resource)
228
244
 
229
- add_route(:get, "/#{resource_name}", resource_options) if actions.include?(:show)
230
- add_route(:put, "/#{resource_name}", resource_options) if actions.include?(:update)
231
- add_route(:delete, "/#{resource_name}", resource_options) if actions.include?(:destroy)
232
- add_route(:post, "/#{resource_name}", resource_options) if actions.include?(:create)
245
+ if actions.include?(:show)
246
+ add_route(:get, "/#{resource_name}",
247
+ resolve_request_model_for(resource_options, :show))
248
+ end
249
+ if actions.include?(:update)
250
+ add_route(:put, "/#{resource_name}",
251
+ resolve_request_model_for(resource_options, :update))
252
+ end
253
+ if actions.include?(:destroy)
254
+ add_route(:delete, "/#{resource_name}",
255
+ resolve_request_model_for(resource_options, :destroy))
256
+ end
257
+ return unless actions.include?(:create)
258
+
259
+ add_route(:post, "/#{resource_name}",
260
+ resolve_request_model_for(resource_options, :create))
233
261
  end
234
262
 
235
263
  private
@@ -271,6 +299,39 @@ module Belt
271
299
  options.merge(tables: [resource_name.to_sym])
272
300
  end
273
301
 
302
+ def add_resource_routes(resource_name, param_name, resource_options, actions)
303
+ if actions.include?(:index)
304
+ add_route(:get, "/#{resource_name}",
305
+ resolve_request_model_for(resource_options, :index))
306
+ end
307
+ if actions.include?(:create)
308
+ add_route(:post, "/#{resource_name}",
309
+ resolve_request_model_for(resource_options, :create))
310
+ end
311
+ if actions.include?(:show)
312
+ add_route(:get, "/#{resource_name}/{#{param_name}}",
313
+ resolve_request_model_for(resource_options, :show))
314
+ end
315
+ if actions.include?(:update)
316
+ add_route(:put, "/#{resource_name}/{#{param_name}}",
317
+ resolve_request_model_for(resource_options, :update))
318
+ end
319
+ return unless actions.include?(:destroy)
320
+
321
+ add_route(:delete, "/#{resource_name}/{#{param_name}}", resolve_request_model_for(resource_options, :destroy))
322
+ end
323
+
324
+ def resolve_request_model_for(options, action)
325
+ rm = options[:request_model]
326
+ case rm
327
+ when Hash
328
+ resolved = rm[action] || rm[action.to_s]
329
+ options.merge(request_model: resolved)
330
+ else
331
+ options
332
+ end
333
+ end
334
+
274
335
  def determine_actions(options, default: %i[index create show update destroy])
275
336
  if options[:only]
276
337
  Array(options[:only])
@@ -540,17 +601,37 @@ module Belt
540
601
  end
541
602
 
542
603
  def add_scoped_resource_routes(resource_name, param_name, resource_options, actions)
543
- @gateway.send(:add_route, :get, build_path("/#{resource_name}"), resource_options) if actions.include?(:index)
544
- @gateway.send(:add_route, :post, build_path("/#{resource_name}"), resource_options) if actions.include?(:create)
604
+ if actions.include?(:index)
605
+ @gateway.send(:add_route, :get, build_path("/#{resource_name}"),
606
+ resolve_request_model_for(resource_options, :index))
607
+ end
608
+ if actions.include?(:create)
609
+ @gateway.send(:add_route, :post, build_path("/#{resource_name}"),
610
+ resolve_request_model_for(resource_options, :create))
611
+ end
545
612
  if actions.include?(:show)
546
- @gateway.send(:add_route, :get, build_path("/#{resource_name}/{#{param_name}}"), resource_options)
613
+ @gateway.send(:add_route, :get, build_path("/#{resource_name}/{#{param_name}}"),
614
+ resolve_request_model_for(resource_options, :show))
547
615
  end
548
616
  if actions.include?(:update)
549
- @gateway.send(:add_route, :put, build_path("/#{resource_name}/{#{param_name}}"), resource_options)
617
+ @gateway.send(:add_route, :put, build_path("/#{resource_name}/{#{param_name}}"),
618
+ resolve_request_model_for(resource_options, :update))
550
619
  end
551
620
  return unless actions.include?(:destroy)
552
621
 
553
- @gateway.send(:add_route, :delete, build_path("/#{resource_name}/{#{param_name}}"), resource_options)
622
+ @gateway.send(:add_route, :delete, build_path("/#{resource_name}/{#{param_name}}"),
623
+ resolve_request_model_for(resource_options, :destroy))
624
+ end
625
+
626
+ def resolve_request_model_for(options, action)
627
+ rm = options[:request_model]
628
+ case rm
629
+ when Hash
630
+ resolved = rm[action] || rm[action.to_s]
631
+ options.merge(request_model: resolved)
632
+ else
633
+ options
634
+ end
554
635
  end
555
636
 
556
637
  def build_scoped_resources(name, options, &block)
@@ -585,13 +666,22 @@ module Belt
585
666
  resource_options = options.merge(route_type: :resource, controller: controller)
586
667
  actions = determine_scoped_actions(options, default: %i[show update destroy create])
587
668
 
588
- @gateway.send(:add_route, :get, build_path("/#{resource_name}"), resource_options) if actions.include?(:show)
589
- @gateway.send(:add_route, :put, build_path("/#{resource_name}"), resource_options) if actions.include?(:update)
669
+ if actions.include?(:show)
670
+ @gateway.send(:add_route, :get, build_path("/#{resource_name}"),
671
+ resolve_request_model_for(resource_options, :show))
672
+ end
673
+ if actions.include?(:update)
674
+ @gateway.send(:add_route, :put, build_path("/#{resource_name}"),
675
+ resolve_request_model_for(resource_options, :update))
676
+ end
590
677
  if actions.include?(:destroy)
591
678
  @gateway.send(:add_route, :delete, build_path("/#{resource_name}"),
592
- resource_options)
679
+ resolve_request_model_for(resource_options, :destroy))
593
680
  end
594
- @gateway.send(:add_route, :post, build_path("/#{resource_name}"), resource_options) if actions.include?(:create)
681
+ return unless actions.include?(:create)
682
+
683
+ @gateway.send(:add_route, :post, build_path("/#{resource_name}"),
684
+ resolve_request_model_for(resource_options, :create))
595
685
  end
596
686
 
597
687
  def apply_scope_to_route(options)
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.3.5'
4
+ VERSION = '0.3.7'
5
5
  end
@@ -161,6 +161,35 @@ belt doctor # Check dependencies
161
161
 
162
162
  4. `resources :things` generates: `GET /things`, `POST /things`, `GET /things/:id`, `PUT /things/:id`, `DELETE /things/:id`. **PUT, not PATCH.**
163
163
 
164
+ ### Request Model Wiring (Validation)
165
+
166
+ Belt auto-wires request validation from `config/contracts.rb` using conventions:
167
+
168
+ - `POST /items` → looks for `:create_item` (or `:create_<gateway>_item`)
169
+ - `PUT /items/:id` → looks for `:update_item` (or `:update_<gateway>_item`)
170
+
171
+ **No `request_model:` declaration needed** if your contract names follow this pattern. Belt infers it.
172
+
173
+ ### Response Model Wiring (Documentation)
174
+
175
+ Belt auto-wires response models the same way:
176
+
177
+ - `resources :items` → looks for `model :item` in contracts → auto-wires `response_model: :item`
178
+
179
+ **No `response_model:` declaration needed** if your model name matches the singular resource name.
180
+
181
+ ### Overrides
182
+
183
+ For per-action overrides when conventions don't fit:
184
+ ```ruby
185
+ resources :items, request_model: { create: :create_item, update: :update_item }
186
+ ```
187
+
188
+ For non-CRUD custom actions, use explicit wiring:
189
+ ```ruby
190
+ post '/schedule', request_model: :schedule_pickup
191
+ ```
192
+
164
193
  ## How Models Work
165
194
 
166
195
  Models use ActiveItem (DynamoDB ORM):
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -132,6 +132,7 @@ files:
132
132
  - lib/belt/cli/path_gem_materializer.rb
133
133
  - lib/belt/cli/plugin_command.rb
134
134
  - lib/belt/cli/routes_command.rb
135
+ - lib/belt/cli/routes_command/request_model_inference.rb
135
136
  - lib/belt/cli/routes_command/route_inference.rb
136
137
  - lib/belt/cli/routes_command/schema_loader.rb
137
138
  - lib/belt/cli/server_command.rb