belt 0.2.20 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 983c166381b409ee19c2a23c1439be35180892299af76570097d206dea1ba1a4
4
- data.tar.gz: f7888dca2b10805bed099da525b968b90e511c1b8381990ba85ab80dc23a9263
3
+ metadata.gz: cacb19365ad96e079f7a796dc74390f70089bff236581a65b30fabb6e5719008
4
+ data.tar.gz: c723291b6e114a285b146fead8f5626fe812d798c2230f90999bde34af67806a
5
5
  SHA512:
6
- metadata.gz: 253f76decf02f960c12011b95570b04213c33800eecfaf70415071f8a2792cae2480a71251cc0ec1b565b837b1c9229f6522c6452124f63560334c6cb6b838d4
7
- data.tar.gz: 5393e1c8cdef68b50dee5bca41a299d02560ecc9e65718e8ed45e2cb09b5ffabaf74eefc11b47e59f35fd7b78b602b866db7028eae82246e0da76b14910eca53
6
+ metadata.gz: 8ac85878df33cec2aa990e527530e54368972e6cca18f18f1e080f4154c50b5d539536fdcb55dfa6b2de675c2f271ed60cb578c3e4acedd4c2bc8c39f48cefd3
7
+ data.tar.gz: fe809339def0c18771870b7bc7bd7740c83aab12dfcef9eb7f4a3b07420bf139dce02fe0c8845bf84e994eb9743e2aa635f0fb0d6e2f0ff369f12b15cabdcc6b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ Version bump only — no code changes from 0.2.21.
6
+
7
+ **Why 0.3.0 instead of another patch?** The route DSL changes in 0.2.21 (`gateway`/`function`/`namespace`/`scope`) were a minor-version level change (new features, new keywords). RubyGems doesn't allow republishing the same version, so we're bumping to 0.3.0 to correct the semver trajectory.
8
+
9
+ If you're on 0.2.21, you already have the DSL changes. This release is purely for version hygiene.
10
+
11
+ ## 0.2.21
12
+
13
+ ### Route DSL: `gateway`, `function`, `namespace`, and `scope` keywords
14
+
15
+ The route DSL now uses terminology that matches AWS infrastructure:
16
+
17
+ - **`gateway`** — Creates an API Gateway with a default Lambda function of the same name (replaces top-level `namespace`)
18
+ - **`function`** — Targets enclosed routes to a different Lambda function
19
+ - **`namespace`** — Rails-like path + module prefix (e.g., `/admin/users` with `admin/users` controller). Does NOT change Lambda target.
20
+ - **`scope`** — Flexible grouping with `path:`, `module:`, `auth:`, `tables:` options. Does NOT change Lambda target.
21
+
22
+ ```ruby
23
+ Belt.application.routes.draw do
24
+ gateway :api, auth: :cognito do
25
+ resources :posts # → lambda: "api", path: /posts
26
+
27
+ function :onboarding do
28
+ resources :steps # → lambda: "onboarding", path: /steps
29
+ end
30
+
31
+ namespace :admin do
32
+ resources :users # → lambda: "api", path: /admin/users, controller: "admin/users"
33
+ end
34
+
35
+ scope path: 'v2', module: 'v2' do
36
+ resources :widgets # → lambda: "api", path: /v2/widgets, controller: "v2/widgets"
37
+ end
38
+ end
39
+ end
40
+ ```
41
+
42
+ **ActionRouter** now accepts `gateway:` keyword (preferred):
43
+
44
+ ```ruby
45
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: 'api')
46
+ ```
47
+
48
+ **100% backward compatible:** `namespace :api do` at top level still works (aliased to `gateway`), and `ActionRouter.new(namespace: 'api')` still works.
49
+
3
50
  ## 0.2.18
4
51
 
5
52
  ### New generator: `belt generate auth`
data/README.md CHANGED
@@ -105,7 +105,7 @@ require "belt"
105
105
 
106
106
  include Belt::LambdaHandler
107
107
 
108
- ROUTER = Belt::ActionRouter.new(routes: Routes::API, namespace: "api")
108
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: "api")
109
109
 
110
110
  def execute(path:, body:, event:)
111
111
  ROUTER.route(event: event, body: body)
@@ -140,7 +140,7 @@ Define routes in `infrastructure/routes.tf.rb`:
140
140
 
141
141
  ```ruby
142
142
  Belt.application.routes.draw do
143
- namespace :api do
143
+ gateway :api do
144
144
  resources :posts, only: [:index, :show, :create]
145
145
  end
146
146
  end
@@ -170,6 +170,45 @@ The provider will:
170
170
  - Generate IAM policies for DynamoDB table access
171
171
  - Set up CloudWatch log groups
172
172
 
173
+ ### Route DSL Keywords
174
+
175
+ The routes DSL has four keywords that map to infrastructure and code organization:
176
+
177
+ | Keyword | Purpose | Affects Lambda? |
178
+ |---------|---------|-----------------|
179
+ | `gateway` | Creates an API Gateway + default Lambda | Yes — sets the default Lambda for all routes inside |
180
+ | `function` | Routes to a different Lambda | Yes — overrides the gateway's default |
181
+ | `namespace` | Adds path prefix + controller module | No — Rails-like code organization only |
182
+ | `scope` | Flexible path/module/auth grouping | No — grouping and shared options only |
183
+
184
+ **Example combining all four:**
185
+
186
+ ```ruby
187
+ Belt.application.routes.draw do
188
+ gateway :api, auth: :cognito do
189
+ resources :posts # → lambda: api, path: /posts, controller: posts
190
+
191
+ namespace :admin do
192
+ resources :users # → lambda: api, path: /admin/users, controller: admin/users
193
+ end
194
+
195
+ function :worker do
196
+ resources :jobs # → lambda: worker, path: /jobs, controller: jobs
197
+
198
+ namespace :internal do
199
+ resources :tasks # → lambda: worker, path: /internal/tasks, controller: internal/tasks
200
+ end
201
+ end
202
+
203
+ scope path: 'v2', module: 'legacy' do
204
+ resources :widgets # → lambda: api, path: /v2/widgets, controller: legacy/widgets
205
+ end
206
+ end
207
+ end
208
+ ```
209
+
210
+ **Key point:** `namespace` and `scope` are purely organizational — they affect URL paths and controller module resolution but never change which Lambda handles the request. Use `function` when you need routes to go to a different Lambda.
211
+
173
212
  ## BeltController Features
174
213
 
175
214
  ### Callbacks
@@ -423,7 +462,7 @@ The command expects `infrastructure/routes.tf.rb` in the current working directo
423
462
 
424
463
  ```ruby
425
464
  Belt.application.routes.draw do
426
- namespace :api do
465
+ gateway :api do
427
466
  resources :posts, only: [:index, :show, :create, :destroy]
428
467
  resource :profile, only: [:show, :update]
429
468
  get "health", action: :health
@@ -7,22 +7,26 @@ module Belt
7
7
  # Routes incoming requests to controllers based on a route manifest.
8
8
  #
9
9
  # Usage:
10
- # ROUTER = Belt::ActionRouter.new(routes: MY_ROUTES, namespace: "api")
10
+ # ROUTER = Belt::ActionRouter.new(routes: MY_ROUTES, gateway: "api")
11
11
  # response = ROUTER.route(event: event, body: body)
12
12
  #
13
13
  class ActionRouter
14
14
  class RouteNotFound < StandardError; end
15
15
 
16
- def initialize(routes:, namespace:)
17
- @namespace = namespace.to_s
18
- @namespace_module_name = "#{@namespace.split('_').map(&:capitalize).join}Controllers"
16
+ def initialize(routes:, gateway: nil, namespace: nil)
17
+ # Accept `gateway:` (preferred) or legacy `namespace:` keyword
18
+ gw = gateway || namespace
19
+ raise ArgumentError, 'Belt::ActionRouter requires a gateway: (or legacy namespace:) argument' unless gw
20
+
21
+ @gateway = gw.to_s
22
+ @gateway_module_name = "#{@gateway.split('_').map(&:capitalize).join}Controllers"
19
23
  @routes = build_route_table(routes)
20
24
  end
21
25
 
22
26
  def route(event:, body:)
23
27
  method = event['httpMethod']
24
28
  full_path = event['path']
25
- match_path = strip_namespace_prefix(full_path)
29
+ match_path = strip_gateway_prefix(full_path)
26
30
 
27
31
  route_info = find_route(method, match_path)
28
32
 
@@ -56,10 +60,10 @@ module Belt
56
60
 
57
61
  private
58
62
 
59
- def strip_namespace_prefix(path)
63
+ def strip_gateway_prefix(path)
60
64
  return '/' if path.nil?
61
65
 
62
- prefix = "/#{@namespace}"
66
+ prefix = "/#{@gateway}"
63
67
  if path.start_with?(prefix)
64
68
  stripped = path.sub(prefix, '')
65
69
  stripped.empty? ? '/' : stripped
@@ -127,15 +131,15 @@ module Belt
127
131
  return false if name.start_with?('/') || name.end_with?('/')
128
132
  return false if name.include?('\\')
129
133
 
130
- # Allow only lowercase letters, digits, underscores, and a single forward slash for nesting
134
+ # Allow only lowercase letters, digits, underscores, and forward slashes for nesting
131
135
  name.match?(%r{\A[a-z][a-z0-9_]*(/[a-z][a-z0-9_]*)?\z})
132
136
  end
133
137
 
134
138
  def resolve_controller(controller_name)
135
- # Try namespace module first (app's own controllers)
139
+ # Try gateway module first (app's own controllers)
136
140
  begin
137
- namespace_module = Object.const_get(@namespace_module_name)
138
- return resolve_from_module(namespace_module, controller_name)
141
+ gateway_module = Object.const_get(@gateway_module_name)
142
+ return resolve_from_module(gateway_module, controller_name)
139
143
  rescue NameError
140
144
  # Fall through to controller_paths lookup
141
145
  end
@@ -144,13 +148,13 @@ module Belt
144
148
  resolve_from_paths(controller_name)
145
149
  end
146
150
 
147
- def resolve_from_module(namespace_module, controller_name)
151
+ def resolve_from_module(gateway_module, controller_name)
148
152
  if controller_name.include?('/')
149
153
  parts = controller_name.split('/')
150
- parent = namespace_module.const_get(parts[0].split('_').map(&:capitalize).join)
154
+ parent = gateway_module.const_get(parts[0].split('_').map(&:capitalize).join)
151
155
  parent.const_get("#{parts[1].split('_').map(&:capitalize).join}Controller")
152
156
  else
153
- namespace_module.const_get("#{controller_name.split('_').map(&:capitalize).join}Controller")
157
+ gateway_module.const_get("#{controller_name.split('_').map(&:capitalize).join}Controller")
154
158
  end
155
159
  end
156
160
 
@@ -176,9 +180,9 @@ module Belt
176
180
  class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
177
181
  return Object.const_get(class_name) if Object.const_defined?(class_name)
178
182
 
179
- # Try under namespace module (e.g., BrablogControllers::PostsController)
180
- if Object.const_defined?(@namespace_module_name)
181
- ns = Object.const_get(@namespace_module_name)
183
+ # Try under gateway module (e.g., ApiControllers::PostsController)
184
+ if Object.const_defined?(@gateway_module_name)
185
+ ns = Object.const_get(@gateway_module_name)
182
186
  return ns.const_get(class_name) if ns.const_defined?(class_name)
183
187
  end
184
188
  end
@@ -3,12 +3,14 @@
3
3
  module Belt
4
4
  module CLI
5
5
  module AppDetection
6
- # Detects the primary namespace from the route definitions.
6
+ # Detects the primary gateway from the route definitions.
7
7
  # Used by generators to determine controller directory and route file naming.
8
8
  def detect_namespace
9
9
  routes_file = find_routes_file_path
10
10
  if routes_file && File.exist?(routes_file)
11
- match = File.read(routes_file).match(/namespace :(\w+)/)
11
+ content = File.read(routes_file)
12
+ # Prefer `gateway :name` but fall back to legacy `namespace :name` at top-level
13
+ match = content.match(/^\s*gateway :(\w+)/) || content.match(/^\s*namespace :(\w+)/)
12
14
  return match[1] if match
13
15
  end
14
16
  File.basename(Dir.pwd)
@@ -145,11 +145,10 @@ module Belt
145
145
 
146
146
  def run
147
147
  validate!
148
+ load_and_apply_env_config!
148
149
  run_preflight_checks!
149
150
  env_dir = File.join(@infra_dir, @env)
150
151
 
151
- load_and_apply_env_config!
152
-
153
152
  puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
154
153
 
155
154
  ensure_lockfile_consistent!
@@ -450,18 +450,25 @@ module Belt
450
450
  elsif content.include?('# resources :posts')
451
451
  content.sub!('# resources :posts', resource_line)
452
452
  else
453
- # Find the target namespace block and insert before its closing `end`
453
+ # Find the target gateway (or legacy namespace) block and insert before its closing `end`
454
+ gateway_pattern = /^(\s*)gateway :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
454
455
  namespace_pattern = /^(\s*)namespace :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
455
456
 
456
- if content.match?(namespace_pattern)
457
- content.sub!(namespace_pattern) do |match|
457
+ target_pattern = if content.match?(gateway_pattern)
458
+ gateway_pattern
459
+ elsif content.match?(namespace_pattern)
460
+ namespace_pattern
461
+ end
462
+
463
+ if target_pattern
464
+ content.sub!(target_pattern) do |match|
458
465
  indent = ::Regexp.last_match(1)
459
466
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
460
467
  end
461
468
  else
462
- single_ns_pattern = /^(\s*)namespace :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
463
- if content.match?(single_ns_pattern)
464
- content.sub!(single_ns_pattern) do |match|
469
+ single_gw_pattern = /^(\s*)(?:gateway|namespace) :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
470
+ if content.match?(single_gw_pattern)
471
+ content.sub!(single_gw_pattern) do |match|
465
472
  indent = ::Regexp.last_match(1)
466
473
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
467
474
  end
@@ -243,7 +243,9 @@ module Belt
243
243
  end
244
244
 
245
245
  def table_name(model_name)
246
- "${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
246
+ # Dasherize to match ActiveItem's table_name_for convention:
247
+ # class_name.underscore.dasherize.pluralize
248
+ "${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name).tr('_', '-')}"
247
249
  end
248
250
  end
249
251
  end
@@ -52,49 +52,46 @@ module Belt
52
52
 
53
53
  class NestedResourceBuilder
54
54
  def initialize(gateway, prefix, collection_prefix, inherited_tables: [], inherited_auth: nil, # rubocop:disable Metrics/ParameterLists
55
- inherited_controller: nil)
55
+ inherited_controller: nil, inherited_lambda: nil)
56
56
  @gateway = gateway
57
57
  @prefix = prefix
58
58
  @collection_prefix = collection_prefix
59
59
  @inherited_tables = inherited_tables
60
60
  @inherited_auth = inherited_auth
61
61
  @inherited_controller = inherited_controller
62
+ @inherited_lambda = inherited_lambda
62
63
  end
63
64
 
64
- def resources(name, options = {})
65
+ def resources(name, options = {}, &block)
65
66
  resource_name = name.to_s
66
67
  singular = @gateway.send(:singularize, resource_name)
67
68
  param_name = options[:param] || "#{singular}_id"
68
- # Auto-add this resource's table before merging inherited tables
69
69
  options = options.merge(tables: [resource_name.to_sym]) unless options.key?(:tables)
70
70
  options = merge_inherited_options(options)
71
71
  resource_options = options.merge(route_type: :resources)
72
72
  actions = @gateway.send(:determine_actions, options)
73
73
 
74
- @gateway.send(:add_route, :get, "#{@prefix}/#{resource_name}", resource_options) if actions.include?(:index)
75
- @gateway.send(:add_route, :post, "#{@prefix}/#{resource_name}", resource_options) if actions.include?(:create)
76
- if actions.include?(:show)
77
- @gateway.send(:add_route, :get, "#{@prefix}/#{resource_name}/{#{param_name}}",
78
- resource_options)
79
- end
80
- if actions.include?(:update)
81
- @gateway.send(:add_route, :put, "#{@prefix}/#{resource_name}/{#{param_name}}",
82
- resource_options)
83
- end
84
- return unless actions.include?(:destroy)
74
+ add_nested_resource_routes(resource_name, param_name, resource_options, actions)
75
+ return unless block
85
76
 
86
- @gateway.send(:add_route, :delete, "#{@prefix}/#{resource_name}/{#{param_name}}",
87
- resource_options)
77
+ nested_member_prefix = "#{@prefix}/#{resource_name}/{#{param_name}}"
78
+ nested_collection_prefix = "#{@prefix}/#{resource_name}"
79
+ nested_builder = NestedResourceBuilder.new(@gateway, nested_member_prefix, nested_collection_prefix,
80
+ inherited_tables: Array(options[:tables] || []),
81
+ inherited_auth: options[:auth] || @inherited_auth,
82
+ inherited_controller: @inherited_controller,
83
+ inherited_lambda: @inherited_lambda)
84
+ nested_builder.instance_eval(&block)
88
85
  end
89
86
 
90
87
  def member(&)
91
88
  MemberCollectionBuilder.new(@gateway, @prefix, @inherited_tables, @inherited_auth,
92
- @inherited_controller).instance_eval(&)
89
+ @inherited_controller, @inherited_lambda).instance_eval(&)
93
90
  end
94
91
 
95
92
  def collection(&)
96
93
  MemberCollectionBuilder.new(@gateway, @collection_prefix, @inherited_tables,
97
- @inherited_auth, @inherited_controller).instance_eval(&)
94
+ @inherited_auth, @inherited_controller, @inherited_lambda).instance_eval(&)
98
95
  end
99
96
 
100
97
  %i[get post put delete patch].each do |method|
@@ -108,6 +105,20 @@ module Belt
108
105
 
109
106
  private
110
107
 
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)
111
+ if actions.include?(:show)
112
+ @gateway.send(:add_route, :get, "#{@prefix}/#{resource_name}/{#{param_name}}", resource_options)
113
+ end
114
+ if actions.include?(:update)
115
+ @gateway.send(:add_route, :put, "#{@prefix}/#{resource_name}/{#{param_name}}", resource_options)
116
+ end
117
+ return unless actions.include?(:destroy)
118
+
119
+ @gateway.send(:add_route, :delete, "#{@prefix}/#{resource_name}/{#{param_name}}", resource_options)
120
+ end
121
+
111
122
  def merge_inherited_options(options)
112
123
  result = options.dup
113
124
  if @inherited_tables.any?
@@ -116,17 +127,20 @@ module Belt
116
127
  end
117
128
  result[:auth] ||= @inherited_auth if @inherited_auth
118
129
  result[:controller] ||= @inherited_controller if @inherited_controller
130
+ result[:lambda] ||= @inherited_lambda if @inherited_lambda
119
131
  result
120
132
  end
121
133
  end
122
134
 
123
135
  class MemberCollectionBuilder
124
- def initialize(gateway, prefix, inherited_tables, inherited_auth, inherited_controller = nil)
136
+ def initialize(gateway, prefix, inherited_tables, inherited_auth, # rubocop:disable Metrics/ParameterLists
137
+ inherited_controller = nil, inherited_lambda = nil)
125
138
  @gateway = gateway
126
139
  @prefix = prefix
127
140
  @inherited_tables = inherited_tables
128
141
  @inherited_auth = inherited_auth
129
142
  @inherited_controller = inherited_controller
143
+ @inherited_lambda = inherited_lambda
130
144
  end
131
145
 
132
146
  %i[get post put delete patch].each do |method|
@@ -147,6 +161,7 @@ module Belt
147
161
  end
148
162
  result[:auth] ||= @inherited_auth if @inherited_auth
149
163
  result[:controller] ||= @inherited_controller if @inherited_controller
164
+ result[:lambda] ||= @inherited_lambda if @inherited_lambda
150
165
  result
151
166
  end
152
167
  end
@@ -198,9 +213,11 @@ module Belt
198
213
  resource_tables = Array(options[:tables] || [])
199
214
  inherited_tables = (@default_tables + resource_tables).uniq
200
215
  inherited_auth = options[:auth] || @default_auth
216
+ inherited_lambda = options[:lambda]
201
217
  nested_builder = NestedResourceBuilder.new(self, member_prefix, collection_prefix,
202
218
  inherited_tables: inherited_tables,
203
- inherited_auth: inherited_auth)
219
+ inherited_auth: inherited_auth,
220
+ inherited_lambda: inherited_lambda)
204
221
  nested_builder.instance_eval(&)
205
222
  end
206
223
 
@@ -283,11 +300,15 @@ module Belt
283
300
  @dsl
284
301
  end
285
302
 
286
- def namespace(name, options = {}, &)
287
- gateway = Belt::ApiGateway.new(name, options)
288
- RouteBuilder.new(gateway).instance_eval(&) if block_given?
289
- @dsl.api_gateways << gateway
303
+ # Primary DSL keyword: defines an API Gateway with a default Lambda function.
304
+ def gateway(name, options = {}, &)
305
+ gw = Belt::ApiGateway.new(name, options)
306
+ RouteBuilder.new(gw).instance_eval(&) if block_given?
307
+ @dsl.api_gateways << gw
290
308
  end
309
+
310
+ # Legacy alias — existing routes files using `namespace` still work.
311
+ alias namespace gateway
291
312
  end
292
313
 
293
314
  def routes
@@ -303,11 +324,40 @@ module Belt
303
324
  @gateway = gateway
304
325
  @scope_prefix = ''
305
326
  @scope_module = nil
327
+ @lambda_target = nil
306
328
  @scope_auth = nil
307
329
  @scope_tables = []
308
330
  @scope_controller = nil
309
331
  end
310
332
 
333
+ # Rails-like `namespace` — adds both a path prefix AND a module prefix.
334
+ # Equivalent to: scope path: "admin", module: "admin"
335
+ #
336
+ # Example:
337
+ # namespace :admin do
338
+ # resources :users # → /admin/users, controller: "admin/users"
339
+ # end
340
+ def namespace(name, options = {}, &)
341
+ segment = name.to_s
342
+ merged = { path: segment, module: segment }.merge(options)
343
+ scope(merged, &)
344
+ end
345
+
346
+ # Rails-like `scope` — groups routes with shared options (path prefix, module,
347
+ # auth, tables, controller).
348
+ #
349
+ # Examples:
350
+ # scope path: "admin" do
351
+ # resources :users # → /admin/users
352
+ # end
353
+ #
354
+ # scope module: "v2" do
355
+ # resources :users # → /users, controller: "v2/users"
356
+ # end
357
+ #
358
+ # scope path: "v1", module: "v1", auth: :cognito do
359
+ # resources :posts # → /v1/posts, controller: "v1/posts", auth: cognito
360
+ # end
311
361
  def scope(options = {}, &)
312
362
  previous_prefix = @scope_prefix
313
363
  previous_module = @scope_module
@@ -315,12 +365,16 @@ module Belt
315
365
  previous_tables = @scope_tables
316
366
  previous_controller = @scope_controller
317
367
 
318
- # Nest path segments (Rails-style): scope path: "a" { scope path: "b" } → "a/b"
368
+ # Nest path segments: scope path: "a" { scope path: "b" } → "a/b"
319
369
  if options.key?(:path)
320
370
  segment = options[:path].to_s.gsub(%r{^/|/$}, '')
321
371
  @scope_prefix = @scope_prefix.to_s.empty? ? segment : "#{@scope_prefix}/#{segment}"
322
372
  end
323
- @scope_module = options[:module] || @scope_module
373
+ # Nest module segments: namespace :admin { namespace :v2 } → "admin/v2"
374
+ if options.key?(:module)
375
+ mod_segment = options[:module].to_s
376
+ @scope_module = @scope_module.to_s.empty? ? mod_segment : "#{@scope_module}/#{mod_segment}"
377
+ end
324
378
  @scope_auth = options[:auth] || @scope_auth
325
379
  @scope_tables = (@scope_tables + Array(options[:tables] || [])).uniq
326
380
  @scope_controller = options[:controller] || @scope_controller
@@ -334,85 +388,76 @@ module Belt
334
388
  @scope_controller = previous_controller
335
389
  end
336
390
 
391
+ # Target a different Lambda function for enclosed routes.
392
+ # Routes within this block will have their :lambda field set to `name`.
393
+ # Does NOT affect path prefixes or controller module resolution.
394
+ #
395
+ # Example:
396
+ # gateway :api, auth: :cognito do
397
+ # resources :posts # → lambda: "api"
398
+ #
399
+ # function :onboarding do
400
+ # resources :stuff # → lambda: "onboarding"
401
+ # end
402
+ #
403
+ # function :custom do
404
+ # get '/blah' # → lambda: "custom"
405
+ # end
406
+ # end
407
+ def function(name, options = {}, &)
408
+ previous_lambda = @lambda_target
409
+ @lambda_target = name.to_s
410
+ # function blocks can also carry auth/tables
411
+ previous_auth = @scope_auth
412
+ previous_tables = @scope_tables
413
+ @scope_auth = options[:auth] if options[:auth]
414
+ @scope_tables = (@scope_tables + Array(options[:tables] || [])).uniq
415
+
416
+ instance_eval(&) if block_given?
417
+
418
+ @lambda_target = previous_lambda
419
+ @scope_auth = previous_auth
420
+ @scope_tables = previous_tables
421
+ end
422
+
337
423
  %i[get post put delete patch].each do |method|
338
424
  define_method(method) do |path, options = {}|
339
425
  full_path = build_path(path)
340
- route_options = options.dup
341
- route_options[:lambda] ||= @scope_module if @scope_module
342
- route_options[:auth] ||= @scope_auth if @scope_auth
343
- route_options[:controller] ||= @scope_controller if @scope_controller
344
- if @scope_tables.any? || route_options[:tables]
345
- route_options[:tables] =
346
- (@scope_tables + Array(route_options[:tables] || [])).uniq
347
- end
426
+ route_options = apply_scope_to_route(options)
348
427
  @gateway.send(method, full_path, route_options)
349
428
  end
350
429
  end
351
430
 
352
- def resources(name, options = {}, &block)
431
+ def resources(name, options = {}, &)
353
432
  options = apply_scope_options(options)
354
433
 
355
- if @scope_prefix.empty?
356
- @gateway.resources(name, options, &block)
357
- else
358
- # When inside a scope, generate routes with the prefix applied.
359
- # Also set the controller explicitly so inference resolves correctly
360
- # (e.g., scope "admin" + resources :users → controller "admin/users").
434
+ if @scope_prefix.empty? && @scope_module.nil?
435
+ @gateway.resources(name, options, &)
436
+ elsif @scope_prefix.empty?
361
437
  resource_name = name.to_s
362
- singular = Belt::Inflector.singularize(resource_name)
363
- param_name = options[:param] || "#{singular}_id"
364
- controller = "#{@scope_prefix}/#{resource_name}"
365
- resource_options = options.merge(route_type: :resources, controller: controller)
366
- actions = determine_scoped_actions(options)
367
-
368
- add_scoped_resource_routes(resource_name, param_name, resource_options, actions)
369
-
370
- if block
371
- collection_prefix = build_path("/#{resource_name}")
372
- member_prefix = build_path("/#{resource_name}/{#{param_name}}")
373
- resource_tables = Array(options[:tables] || [])
374
- inherited_tables = (@gateway.default_tables + resource_tables).uniq
375
- inherited_auth = options[:auth] || @gateway.default_auth
376
- nested_builder = NestedResourceBuilder.new(@gateway, member_prefix, collection_prefix,
377
- inherited_tables: inherited_tables,
378
- inherited_auth: inherited_auth,
379
- inherited_controller: controller)
380
- nested_builder.instance_eval(&block)
381
- end
438
+ controller = determine_scoped_controller(resource_name)
439
+ options = options.merge(controller: controller) unless options[:controller]
440
+ @gateway.resources(name, options, &)
441
+ else
442
+ build_scoped_resources(name, options, &)
382
443
  end
383
444
  end
384
445
 
385
446
  def resource(name, options = {})
386
447
  options = apply_scope_options(options)
387
448
 
388
- if @scope_prefix.empty?
449
+ if @scope_prefix.empty? && @scope_module.nil?
389
450
  @gateway.resource(name, options)
390
- else
451
+ elsif @scope_prefix.empty?
391
452
  resource_name = name.to_s
392
- controller = "#{@scope_prefix}/#{resource_name}"
393
- resource_options = options.merge(route_type: :resource, controller: controller)
394
- actions = determine_scoped_actions(options, default: %i[show update destroy create])
395
-
396
- @gateway.send(:add_route, :get, build_path("/#{resource_name}"), resource_options) if actions.include?(:show)
397
- if actions.include?(:update)
398
- @gateway.send(:add_route, :put, build_path("/#{resource_name}"),
399
- resource_options)
400
- end
401
- if actions.include?(:destroy)
402
- @gateway.send(:add_route, :delete, build_path("/#{resource_name}"),
403
- resource_options)
404
- end
405
- if actions.include?(:create)
406
- @gateway.send(:add_route, :post, build_path("/#{resource_name}"),
407
- resource_options)
408
- end
453
+ controller = determine_scoped_controller(resource_name)
454
+ options = options.merge(controller: controller) unless options[:controller]
455
+ @gateway.resource(name, options)
456
+ else
457
+ build_scoped_resource(name, options)
409
458
  end
410
459
  end
411
460
 
412
- def lambda(name, &)
413
- name
414
- end
415
-
416
461
  def mount(mountable, options = {})
417
462
  prefix = options[:at]&.to_s&.gsub(%r{^/|/$}, '') || ''
418
463
  extra_tables = Array(options[:tables] || [])
@@ -457,6 +502,16 @@ module Belt
457
502
  @scope_prefix.empty? ? path : "/#{@scope_prefix}#{path}"
458
503
  end
459
504
 
505
+ def determine_scoped_controller(resource_name)
506
+ if @scope_module && !@scope_module.empty?
507
+ "#{@scope_module}/#{resource_name}"
508
+ elsif !@scope_prefix.empty?
509
+ "#{@scope_prefix}/#{resource_name}"
510
+ else
511
+ resource_name
512
+ end
513
+ end
514
+
460
515
  def determine_scoped_actions(options, default: %i[index create show update destroy])
461
516
  if options[:only]
462
517
  Array(options[:only])
@@ -481,10 +536,65 @@ module Belt
481
536
  @gateway.send(:add_route, :delete, build_path("/#{resource_name}/{#{param_name}}"), resource_options)
482
537
  end
483
538
 
539
+ def build_scoped_resources(name, options, &block)
540
+ resource_name = name.to_s
541
+ singular = Belt::Inflector.singularize(resource_name)
542
+ param_name = options[:param] || "#{singular}_id"
543
+ controller = options[:controller] || determine_scoped_controller(resource_name)
544
+ resource_options = options.merge(route_type: :resources, controller: controller)
545
+ actions = determine_scoped_actions(options)
546
+
547
+ add_scoped_resource_routes(resource_name, param_name, resource_options, actions)
548
+ build_nested_resource_block(resource_name, param_name, options, controller, &block) if block
549
+ end
550
+
551
+ def build_nested_resource_block(resource_name, param_name, options, controller, &)
552
+ collection_prefix = build_path("/#{resource_name}")
553
+ member_prefix = build_path("/#{resource_name}/{#{param_name}}")
554
+ resource_tables = Array(options[:tables] || [])
555
+ inherited_tables = (@gateway.default_tables + resource_tables).uniq
556
+ inherited_auth = options[:auth] || @gateway.default_auth
557
+ nested_builder = NestedResourceBuilder.new(@gateway, member_prefix, collection_prefix,
558
+ inherited_tables: inherited_tables,
559
+ inherited_auth: inherited_auth,
560
+ inherited_controller: controller,
561
+ inherited_lambda: @lambda_target)
562
+ nested_builder.instance_eval(&)
563
+ end
564
+
565
+ def build_scoped_resource(name, options)
566
+ resource_name = name.to_s
567
+ controller = options[:controller] || determine_scoped_controller(resource_name)
568
+ resource_options = options.merge(route_type: :resource, controller: controller)
569
+ actions = determine_scoped_actions(options, default: %i[show update destroy create])
570
+
571
+ @gateway.send(:add_route, :get, build_path("/#{resource_name}"), resource_options) if actions.include?(:show)
572
+ @gateway.send(:add_route, :put, build_path("/#{resource_name}"), resource_options) if actions.include?(:update)
573
+ if actions.include?(:destroy)
574
+ @gateway.send(:add_route, :delete, build_path("/#{resource_name}"),
575
+ resource_options)
576
+ end
577
+ @gateway.send(:add_route, :post, build_path("/#{resource_name}"), resource_options) if actions.include?(:create)
578
+ end
579
+
580
+ def apply_scope_to_route(options)
581
+ route_options = options.dup
582
+ # Lambda target: only explicit function block affects it
583
+ route_options[:lambda] ||= @lambda_target if @lambda_target
584
+ route_options[:auth] ||= @scope_auth if @scope_auth
585
+ route_options[:controller] ||= @scope_controller if @scope_controller
586
+ if @scope_tables.any? || route_options[:tables]
587
+ route_options[:tables] =
588
+ (@scope_tables + Array(route_options[:tables] || [])).uniq
589
+ end
590
+ route_options
591
+ end
592
+
484
593
  def apply_scope_options(options)
485
594
  result = options.dup
486
595
  result[:auth] ||= @scope_auth if @scope_auth
487
- result[:lambda] ||= @scope_module if @scope_module
596
+ # Lambda target: only explicit function block affects it
597
+ result[:lambda] ||= @lambda_target if @lambda_target
488
598
  result[:controller] ||= @scope_controller if @scope_controller
489
599
  result[:tables] = (@scope_tables + Array(result[:tables] || [])).uniq if @scope_tables.any? || result[:tables]
490
600
  result
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.2.20'
4
+ VERSION = '0.3.1'
5
5
  end
@@ -51,9 +51,7 @@ resource "aws_cognito_user_pool_client" "<%= pool[:name] %>" {
51
51
 
52
52
  explicit_auth_flows = [
53
53
  "ALLOW_USER_SRP_AUTH",
54
- <% if @signup -%>
55
54
  "ALLOW_USER_PASSWORD_AUTH",
56
- <% end -%>
57
55
  "ALLOW_REFRESH_TOKEN_AUTH"
58
56
  ]
59
57
 
@@ -55,7 +55,11 @@ export async function completeNewPassword(username, newPassword, session) {
55
55
  ChallengeName: 'NEW_PASSWORD_REQUIRED',
56
56
  ClientId: CLIENT_ID,
57
57
  Session: session,
58
- ChallengeResponses: { USERNAME: username, NEW_PASSWORD: newPassword }
58
+ ChallengeResponses: {
59
+ USERNAME: username,
60
+ NEW_PASSWORD: newPassword,
61
+ 'userAttributes.email': username
62
+ }
59
63
  }))
60
64
  setTokens(response.AuthenticationResult)
61
65
  return { success: true }
@@ -56,13 +56,13 @@ belt output <env> # terraform output
56
56
  1. `config/routes.rb` defines routes using a DSL:
57
57
  ```ruby
58
58
  Belt.application.routes.draw do
59
- namespace :<%= @app_name %> do
59
+ gateway :<%= @app_name %> do
60
60
  resources :things, tables: [:things]
61
61
  end
62
62
  end
63
63
  ```
64
64
 
65
- 2. Conveyor Belt creates an API Gateway where the namespace becomes a base path mapping. URLs look like:
65
+ 2. Conveyor Belt creates an API Gateway where the gateway name becomes a base path mapping. URLs look like:
66
66
  ```
67
67
  https://api.<env>.example.com/<%= @app_name %>/things
68
68
  ```
@@ -1,5 +1,7 @@
1
1
  Belt.application.routes.draw do
2
- namespace :api do
2
+ # Creates an API Gateway named "api" with a default Lambda function of the same name.
3
+ # Use `function :other_name do ... end` inside to route specific paths to additional Lambdas.
4
+ gateway :api do
3
5
  # Public stack-check / welcome (HTML for browsers, JSON for the SPA shell)
4
6
  get "/", action: :show, controller: :welcome, auth: :none
5
7
  # resources :posts
@@ -9,7 +9,7 @@ require_relative 'lib/routes/api_routes'
9
9
  require_relative 'controllers/<%= @app_name %>/<%= r %>_controller'
10
10
  <% end -%>
11
11
 
12
- ROUTER = Belt::ActionRouter.new(routes: Routes::API, namespace: 'api')
12
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: 'api')
13
13
 
14
14
  def execute(path:, body:, event:)
15
15
  ROUTER.route(event: event, body: body)
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.2.20
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla