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 +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +42 -3
- data/lib/belt/action_router.rb +21 -17
- data/lib/belt/cli/app_detection.rb +4 -2
- data/lib/belt/cli/deploy_command.rb +1 -2
- data/lib/belt/cli/generate_command.rb +13 -6
- data/lib/belt/cli/tables_command.rb +3 -1
- data/lib/belt/route_dsl.rb +195 -85
- data/lib/belt/version.rb +1 -1
- data/lib/templates/generate/auth/cognito.tf.erb +0 -2
- data/lib/templates/generate/auth/frontend/auth.js +5 -1
- data/lib/templates/new_app/AGENTS.md.erb +2 -2
- data/lib/templates/new_app/config/routes.rb.erb +3 -1
- data/lib/templates/new_app/lambda/api.rb.erb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cacb19365ad96e079f7a796dc74390f70089bff236581a65b30fabb6e5719008
|
|
4
|
+
data.tar.gz: c723291b6e114a285b146fead8f5626fe812d798c2230f90999bde34af67806a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/belt/action_router.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
18
|
-
|
|
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 =
|
|
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
|
|
63
|
+
def strip_gateway_prefix(path)
|
|
60
64
|
return '/' if path.nil?
|
|
61
65
|
|
|
62
|
-
prefix = "/#{@
|
|
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
|
|
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
|
|
139
|
+
# Try gateway module first (app's own controllers)
|
|
136
140
|
begin
|
|
137
|
-
|
|
138
|
-
return resolve_from_module(
|
|
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(
|
|
151
|
+
def resolve_from_module(gateway_module, controller_name)
|
|
148
152
|
if controller_name.include?('/')
|
|
149
153
|
parts = controller_name.split('/')
|
|
150
|
-
parent =
|
|
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
|
-
|
|
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
|
|
180
|
-
if Object.const_defined?(@
|
|
181
|
-
ns = Object.const_get(@
|
|
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
|
|
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
|
-
|
|
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?(
|
|
457
|
-
|
|
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
|
-
|
|
463
|
-
if content.match?(
|
|
464
|
-
content.sub!(
|
|
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
|
-
|
|
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
|
data/lib/belt/route_dsl.rb
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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,
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 = {}, &
|
|
431
|
+
def resources(name, options = {}, &)
|
|
353
432
|
options = apply_scope_options(options)
|
|
354
433
|
|
|
355
|
-
if @scope_prefix.empty?
|
|
356
|
-
@gateway.resources(name, options, &
|
|
357
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
451
|
+
elsif @scope_prefix.empty?
|
|
391
452
|
resource_name = name.to_s
|
|
392
|
-
controller =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
@@ -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: {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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)
|