belt 0.1.0 → 0.1.2

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: d587a60fbaef9f5755a1746b766d5fdc6e6bf6efbc34ee58e3901b0324fbc199
4
- data.tar.gz: ac3bca34eb3552cf18693605b4c42ba11d11ea1d5437d201b6db838443951c28
3
+ metadata.gz: 6527c1a9570ed74381b685347d178f0e20db869d39791c06b5685a45ba94c4dd
4
+ data.tar.gz: b60a0994c0646820e74eaf8d81918b4b52b95b570f9fc9ca3d74994b36bbfc6b
5
5
  SHA512:
6
- metadata.gz: fdcd4a4c7efbce1ef89e394f86f3b3252307f4e4d1462ff8582bd0de6fb5ce9d3e7d34cceacc43dd3d50c3165ef1e7f01592b220dcddeb246d81c371fcb59281
7
- data.tar.gz: 8ba38580f372f91d188aad8fc843435dc7f35ef90b7142027f9201317761f4de231d310ab58f5f5536400d6060925d425fcc8d208716381048f96bb6db15b574
6
+ metadata.gz: eb607dbfcc355a2b79dc82b3117f9e10937f32b9449cf6d3861685130f63271207d9b94992d1d86a5f49396acc0d7756f485b6c0db24d3aa70d738d95601fb6e
7
+ data.tar.gz: 1aa7b6eee506bc68947a8eaacc3cf773cb4929207831d21b9fac098bcf82de3a6062918faa0244bce389d4d5f680d4508f49a6e2c5a36dc843a82ad1d2164393
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### `belt routes` CLI command
6
+
7
+ - Added `belt routes` — displays all routes defined in `infrastructure/routes.tf.rb`
8
+ - Concise table output with VERB, PATH, CONTROLLER#ACTION (shows GATEWAY/LAMBDA columns when multiple namespaces exist)
9
+ - JSON output via `--format json` includes routes array and optional schema models
10
+ - Filter routes with `--grep PATTERN` (case-insensitive, matches verb, path, gateway, lambda, controller, or action)
11
+ - Generate Ruby route manifest files with `--namespace NAMESPACE` (or `all` for every gateway/lambda)
12
+ - `--output-dir DIR` controls where generated files are written (warns if used without `--namespace`)
13
+ - Added `Belt.root` — project root detection by walking up to find `infrastructure/routes.tf.rb`, with fallback to `pwd`
14
+ - Default output directory for generated routes: `#{Belt.root}/lambda/lib/routes`
15
+
16
+ ### `belt tasks` CLI command
17
+
18
+ - Added `belt tasks` — lists available rake tasks from the project's Rakefile
19
+ - Filter tasks with `--grep PATTERN`
20
+ - Show all tasks (including undescribed) with `--all`
21
+ - Run rake tasks directly: `belt lambda:build_layer` invokes `bundle exec rake lambda:build_layer`
22
+
23
+ ### Other changes
24
+
25
+ - Added `Belt::RouteDSL` — full route DSL parser (resources, nested resources, scopes, mounts, schemas)
26
+ - Added `Belt::TableInference` — infers DynamoDB table access from Terraform definitions
27
+ - Renamed `TerraDispatch` references to `Belt` in templates and DSL entry points
28
+ - Removed `activeitem` dependency from generated Gemfile template
29
+ - Added Rakefile template to `belt new` scaffolding
30
+
3
31
  ## 0.0.7
4
32
 
5
33
  - Fixed `discover_gem_paths` to use `Gem.loaded_specs` instead of `Gem::Specification.each` — the latter silently returns nothing on Lambda's vendored bundle layout, causing gem controllers/models to not be found
@@ -26,4 +54,4 @@
26
54
  - Added `BeltController::Base` with callbacks, strong params, CORS, error handling
27
55
  - Added `ActionController::Parameters` (strong params without Rails)
28
56
  - Added response helpers and CORS origin resolution
29
- - Bundled dependencies: activeitem, lambda_loadout, s3arch
57
+ - Bundled dependencies: activeitem, lambda_loadout
data/README.md CHANGED
@@ -9,7 +9,6 @@ Belt bundles everything you need to go from zero to production:
9
9
  - **Belt::ActionRouter** — request routing to controllers from route manifests
10
10
  - **ActiveItem** — DynamoDB ORM (queries, validations, associations, transactions)
11
11
  - **Lambda Loadout** — structured logging, CloudWatch metrics (EMF), error alerting
12
- - **S3arch** — full-text search via SQLite FTS5, stored on S3, queried from Lambda
13
12
 
14
13
  ## Installation
15
14
 
@@ -35,6 +34,8 @@ my-app/
35
34
  │ ├── routes.tf.rb # Belt provider route definitions
36
35
  │ └── schema.tf.rb # DynamoDB table schemas
37
36
  ├── lambda/
37
+ │ ├── config/
38
+ │ │ └── environment.rb # App boot file (used by console + Lambda)
38
39
  │ ├── controllers/
39
40
  │ │ └── posts_controller.rb
40
41
  │ ├── models/
@@ -42,6 +43,7 @@ my-app/
42
43
  │ ├── lib/
43
44
  │ │ └── routes.rb
44
45
  │ └── api.rb # Lambda entry point
46
+ ├── .irbrc # Console customization (optional)
45
47
  ├── Gemfile
46
48
  └── Gemfile.lock
47
49
  ```
@@ -129,7 +131,7 @@ terraform {
129
131
  Define routes in `infrastructure/routes.tf.rb`:
130
132
 
131
133
  ```ruby
132
- TerraDispatch.routes.draw do
134
+ Belt.application.routes.draw do
133
135
  namespace :api do
134
136
  resources :posts, only: [:index, :show, :create]
135
137
  end
@@ -139,7 +141,7 @@ end
139
141
  Define tables in `infrastructure/schema.tf.rb`:
140
142
 
141
143
  ```ruby
142
- TerraDispatch.schema.define do
144
+ Belt.application.schema.define do
143
145
  model :post do
144
146
  partition_key :id, :string
145
147
  global_secondary_index :UserIndex, partition_key: :user_id
@@ -201,80 +203,196 @@ error_response("Not found", 404) # 404 JSON error
201
203
  html_response("<h1>Hello</h1>") # 200 HTML with CORS
202
204
  ```
203
205
 
204
- ## Holsters (Belt's Engines)
206
+ ## Controller Discovery
205
207
 
206
- Holsters are Belt's equivalent of Rails Engines. A holster lets a gem provide its own controllers, models, routes, and schema all discovered automatically via convention.
208
+ Belt discovers controllers from the app's namespace module first, then searches `Belt.all_controller_paths` which includes app-defined paths. No registration needed.
207
209
 
208
- ### Creating a Holster
210
+ ## Belt::Observability
209
211
 
210
- In your gem, subclass `Belt::Holster`:
212
+ Belt provides global `Belt::Observability::Logger` and `Belt::Observability::Metrics` facades that are set automatically by `Belt::LambdaHandler`. Access them from anywhere:
211
213
 
212
214
  ```ruby
213
- # lib/s3arch/holster.rb
214
- module S3arch
215
- class Holster < Belt::Holster
216
- end
217
- end
215
+ Belt::Observability::Logger.info("Something happened", user_id: "123")
216
+ Belt::Observability::Metrics.track_event("OrderCreated", model: "Order")
218
217
  ```
219
218
 
220
- That's it. Belt discovers all `Holster` subclasses at boot. By convention, it expects:
219
+ ## Environment Variables
220
+
221
+ | Variable | Purpose |
222
+ |----------|---------|
223
+ | `ENVIRONMENT` | Controls verbose error responses (`dev*`, `local`, `test`) |
224
+ | `BELT_METRICS_NAMESPACE` | CloudWatch metrics namespace (default: `Belt`) |
225
+ | `ACTION` | Service name for logging (falls back to function name) |
226
+ | `ERROR_NOTIFICATION_TOPIC_ARN` | SNS topic for error alerts |
227
+ | `CORS_ALLOWED_ORIGINS` | Comma-separated origins (overrides domain vars) |
228
+ | `CUSTOMER_APP_DOMAIN` | Primary app domain for CORS |
229
+ | `OPS_APP_DOMAIN` | Internal tools domain for CORS |
230
+
231
+ ## CLI
221
232
 
233
+ Belt includes a command-line interface for project management.
234
+
235
+ ### `belt console` (alias: `belt c`)
236
+
237
+ Start an interactive Ruby console with your app loaded. Belt uses convention over configuration:
238
+
239
+ 1. Loads `lambda/config/environment.rb` (your app's boot file — AWS setup, models, libs)
240
+ 2. Starts IRB with `reload!` available
241
+ 3. Reads `.irbrc` from the project root (console-specific customization)
242
+
243
+ ```bash
244
+ belt console # uses BELT_ENV or defaults to 'dev'
245
+ belt c prod # specify environment explicitly
246
+ belt c dev02 --run "Customer.first" # runner mode (execute and exit)
222
247
  ```
223
- your-gem/
224
- ├── infrastructure/
225
- │ ├── routes.tf.rb # Holster's route definitions
226
- │ └── schema.tf.rb # Holster's DynamoDB tables
227
- └── lambda/
228
- ├── controllers/ # Holster's controllers
229
- └── models/ # Holster's models
248
+
249
+ A production safety prompt is shown when the environment is `prod`.
250
+
251
+ ### `belt routes`
252
+
253
+ Display route definitions from your `infrastructure/routes.tf.rb`. This is the primary way to inspect what endpoints your app exposes.
254
+
255
+ ```bash
256
+ belt routes
230
257
  ```
231
258
 
232
- No configuration needed if you follow the convention. Belt resolves paths relative to your gem's root (two directories up from the holster file).
259
+ Output (single namespace):
233
260
 
234
- ### Customizing Paths
261
+ ```
262
+ VERB PATH CONTROLLER#ACTION
263
+ ------------------------------------------------------------------
264
+ GET /posts posts#index
265
+ GET /posts/{post_id} posts#show
266
+ POST /posts posts#create
267
+ DELETE /posts/{post_id} posts#destroy
268
+ ```
235
269
 
236
- If your gem uses a different layout, override any path:
270
+ When multiple namespaces (API Gateways) exist, GATEWAY and LAMBDA columns are added automatically:
237
271
 
238
- ```ruby
239
- module MyGem
240
- class Holster < Belt::Holster
241
- self.gem_root = File.expand_path("..", __dir__)
242
- self.controllers_path = File.join(gem_root, "app", "controllers")
243
- end
244
- end
272
+ ```
273
+ VERB PATH GATEWAY LAMBDA CONTROLLER#ACTION
274
+ ---------------------------------------------------------------
275
+ GET /posts blog blog posts#index
276
+ POST /posts blog blog posts#create
277
+ GET /posts ops ops posts#index
278
+ POST /posts ops ops posts#create
245
279
  ```
246
280
 
247
- ### How Belt Uses Holsters
281
+ #### Options
248
282
 
249
- - **Controllers**: `Belt::ActionRouter` searches holster controller paths automatically
250
- - **Routes**: `Belt.all_routes_paths` collects all holster `routes.tf.rb` files for the Terraform provider
251
- - **Schema**: `Belt.all_schema_paths` collects all holster `schema.tf.rb` files for the Terraform provider
252
- - **Models**: `Belt.all_models_paths` collects all holster model directories
283
+ | Flag | Description |
284
+ |------|-------------|
285
+ | `-g, --grep PATTERN` | Filter routes matching pattern (case-insensitive, matches verb, path, gateway, lambda, controller, or action) |
286
+ | `-f, --format FORMAT` | Output format: `concise` (default) or `json` |
287
+ | `--namespace NAMESPACE` | Generate Ruby route files for NAMESPACE (or "all") |
288
+ | `--output-dir DIR` | Output directory for generated Ruby files (default: `lambda/lib/routes/`) |
289
+ | `--schema FILE` | Path to `schema.tf.rb` for model definitions (default: same directory as routes file) |
290
+ | `--tables-file FILE` | Path to Terraform file with `aws_dynamodb_table` resources for table inference |
291
+ | `-h, --help` | Show help |
253
292
 
254
- ## Controller Discovery
293
+ #### Examples
255
294
 
256
- Belt discovers controllers from the app's namespace module first, then searches `Belt.all_controller_paths` — which includes both app-defined paths and holster-provided paths. No registration needed.
295
+ ```bash
296
+ # Filter routes by pattern
297
+ belt routes -g posts
257
298
 
258
- ## Belt::Observability
299
+ # JSON output (for tooling/CI)
300
+ belt routes -f json
259
301
 
260
- Belt provides global `Belt::Observability::Logger` and `Belt::Observability::Metrics` facades that are set automatically by `Belt::LambdaHandler`. Access them from anywhere:
302
+ # Generate Ruby route constant for the "api" namespace
303
+ belt routes --namespace api
304
+
305
+ # Generate to a custom directory
306
+ belt routes --namespace api --output-dir lib/routes
307
+
308
+ # Include schema models in JSON output
309
+ belt routes -f json --schema infrastructure/schema.tf.rb
310
+
311
+ # Infer DynamoDB table access from Terraform
312
+ belt routes -f json --tables-file infrastructure/main.tf
313
+ ```
314
+
315
+ #### JSON Output
316
+
317
+ With `--format json`, the output includes a `routes` array and optionally a `models` array (when a schema file is found):
318
+
319
+ ```json
320
+ {
321
+ "routes": [
322
+ {
323
+ "name": "posts",
324
+ "verb": "GET",
325
+ "path": "/posts",
326
+ "gateway": "api",
327
+ "lambda": "api",
328
+ "controller": "posts",
329
+ "action": "index",
330
+ "auth": "cognito",
331
+ "tables": ["posts"],
332
+ "request_model": "",
333
+ "response_model": ""
334
+ }
335
+ ],
336
+ "models": [
337
+ {
338
+ "name": "CreatePost",
339
+ "kind": "request",
340
+ "description": "Request model: CreatePost",
341
+ "properties": {
342
+ "title": { "type": "string" },
343
+ "body": { "type": "string" }
344
+ },
345
+ "required": ["title"]
346
+ }
347
+ ]
348
+ }
349
+ ```
350
+
351
+ #### Ruby Output
352
+
353
+ With `--namespace NAMESPACE`, Belt generates a frozen Ruby constant file at `lambda/lib/routes/<namespace>_routes.rb`:
261
354
 
262
355
  ```ruby
263
- Belt::Observability::Logger.info("Something happened", user_id: "123")
264
- Belt::Observability::Metrics.track_event("OrderCreated", model: "Order")
356
+ # frozen_string_literal: true
357
+
358
+ # Auto-generated by: belt routes --namespace api
359
+ # Do not edit manually
360
+
361
+ module Routes
362
+ API = [
363
+ {
364
+ verb: "GET",
365
+ path: "/posts",
366
+ gateway: "api",
367
+ lambda: "api",
368
+ controller: "posts",
369
+ action: "index",
370
+ auth: "cognito",
371
+ tables: ["posts"]
372
+ }
373
+ ].freeze
374
+ end
265
375
  ```
266
376
 
267
- ## Environment Variables
377
+ This is used by `Belt::ActionRouter` at runtime for request routing.
268
378
 
269
- | Variable | Purpose |
270
- |----------|---------|
271
- | `ENVIRONMENT` | Controls verbose error responses (`dev*`, `local`, `test`) |
272
- | `BELT_METRICS_NAMESPACE` | CloudWatch metrics namespace (default: `Belt`) |
273
- | `ACTION` | Service name for logging (falls back to function name) |
274
- | `ERROR_NOTIFICATION_TOPIC_ARN` | SNS topic for error alerts |
275
- | `CORS_ALLOWED_ORIGINS` | Comma-separated origins (overrides domain vars) |
276
- | `CUSTOMER_APP_DOMAIN` | Primary app domain for CORS |
277
- | `OPS_APP_DOMAIN` | Internal tools domain for CORS |
379
+ #### Route File Location
380
+
381
+ The command expects `infrastructure/routes.tf.rb` in the current working directory. Routes are defined using the same DSL as the Belt Terraform provider:
382
+
383
+ ```ruby
384
+ Belt.application.routes.draw do
385
+ namespace :api do
386
+ resources :posts, only: [:index, :show, :create, :destroy]
387
+ resource :profile, only: [:show, :update]
388
+ get "health", action: :health
389
+ end
390
+ end
391
+ ```
392
+
393
+ #### Table Inference
394
+
395
+ When `--tables-file` is provided, Belt parses `aws_dynamodb_table` resource blocks from your Terraform files and infers which tables each route accesses based on the resource name in the route path. Routes can also declare tables explicitly in the DSL via `tables: [:posts, :comments]`.
278
396
 
279
397
  ## License
280
398
 
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Belt
6
+ module CLI
7
+ class ConsoleCommand
8
+ def self.run(args)
9
+ new(args).run
10
+ end
11
+
12
+ def initialize(args)
13
+ @args = args
14
+ @options = {}
15
+ parse_options
16
+ end
17
+
18
+ def run
19
+ ENV['BUNDLE_GEMFILE'] ||= File.join(Belt.root, 'Gemfile')
20
+
21
+ unless File.exist?(ENV['BUNDLE_GEMFILE'])
22
+ abort "Error: No Gemfile found at #{ENV['BUNDLE_GEMFILE']}. Are you in a Belt project?"
23
+ end
24
+
25
+ @environment = @args.first || ENV['BELT_ENV'] || 'dev'
26
+ ENV['ENVIRONMENT'] = @environment
27
+
28
+ if @options[:run]
29
+ exec_runner(@options[:run])
30
+ else
31
+ exec_console
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def parse_options
38
+ OptionParser.new do |opts|
39
+ opts.banner = 'Usage: belt console [environment] [options]'
40
+ opts.on('--run COMMAND', 'Execute a command and exit') { |cmd| @options[:run] = cmd }
41
+ opts.on('-h', '--help', 'Show this help') do
42
+ puts opts
43
+ exit
44
+ end
45
+ end.parse!(@args)
46
+ end
47
+
48
+ def exec_console
49
+ production_guard!
50
+ boot_app
51
+ puts banner
52
+ ARGV.clear
53
+ require 'irb'
54
+ IRB.start
55
+ end
56
+
57
+ def exec_runner(command)
58
+ boot_app
59
+ result = eval(command) # rubocop:disable Security/Eval
60
+ puts format_result(result)
61
+ rescue StandardError => e
62
+ abort "Error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
63
+ end
64
+
65
+ def boot_app
66
+ require 'bundler/setup'
67
+
68
+ environment_file = File.join(Belt.root, 'lambda', 'config', 'environment.rb')
69
+ if File.exist?(environment_file)
70
+ load environment_file
71
+ else
72
+ require 'belt'
73
+ load_dir('lib')
74
+ load_dir('models')
75
+ end
76
+
77
+ define_reload!
78
+ end
79
+
80
+ def load_dir(subdir)
81
+ dir = File.join(Belt.root, 'lambda', subdir)
82
+ Dir.glob(File.join(dir, '**', '*.rb')).each { |f| require f } if Dir.exist?(dir)
83
+ end
84
+
85
+ def define_reload!
86
+ root = Belt.root
87
+ Kernel.define_method(:reload!) do
88
+ %w[lib models].each do |subdir|
89
+ dir = File.join(root, 'lambda', subdir)
90
+ Dir.glob(File.join(dir, '**', '*.rb')).each { |f| load f } if Dir.exist?(dir)
91
+ end
92
+ puts '♻️ Reloaded'
93
+ end
94
+ end
95
+
96
+ def production_guard!
97
+ return unless @environment == 'prod'
98
+
99
+ $stdout.write "\n⚠️ WARNING: You are entering the PRODUCTION console!\nType 'yes' to continue: "
100
+ response = $stdin.gets&.chomp
101
+ abort "\n❌ Cancelled." unless response&.downcase == 'yes'
102
+ puts "\n✅ Entering production console...\n"
103
+ end
104
+
105
+ def banner
106
+ <<~BANNER
107
+
108
+ Belt Console (#{@environment})
109
+ Type 'reload!' to reload code.
110
+
111
+ BANNER
112
+ end
113
+
114
+ def format_result(result)
115
+ require 'json'
116
+ if result.respond_to?(:to_h)
117
+ JSON.pretty_generate(result.to_h)
118
+ elsif result.respond_to?(:map) && result.respond_to?(:first) && result.first.respond_to?(:to_h)
119
+ JSON.pretty_generate(result.map(&:to_h))
120
+ elsif result.nil?
121
+ 'nil'
122
+ else
123
+ result.inspect
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -71,6 +71,7 @@ module Belt
71
71
  #{@app_name}/lambda/models
72
72
  #{@app_name}/lambda/models/concerns
73
73
  #{@app_name}/lambda/lib/routes
74
+ #{@app_name}/lambda/config
74
75
  #{@app_name}/lambda/spec
75
76
  #{@app_name}/infrastructure
76
77
  ]
@@ -79,8 +80,9 @@ module Belt
79
80
  def files
80
81
  {
81
82
  'Gemfile.erb' => "#{@app_name}/Gemfile",
82
- 'lambda/Gemfile.erb' => "#{@app_name}/lambda/Gemfile",
83
+ 'Rakefile.erb' => "#{@app_name}/Rakefile",
83
84
  'lambda/api.rb.erb' => "#{@app_name}/lambda/#{@app_name}.rb",
85
+ 'lambda/config/environment.rb.erb' => "#{@app_name}/lambda/config/environment.rb",
84
86
  'lambda/models/application_record.rb.erb' => "#{@app_name}/lambda/models/application_record.rb",
85
87
  'lambda/models/concerns/timestampable.rb.erb' => "#{@app_name}/lambda/models/concerns/timestampable.rb",
86
88
  'lambda/controllers/application_controller.rb.erb' =>
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ class RoutesCommand
6
+ # Extracts route controller/action inference logic from RoutesCommand.
7
+ module RouteInference
8
+ private
9
+
10
+ def infer_controller(route, gateway)
11
+ return route.controller.to_s if route.controller
12
+
13
+ segments = route.path.split('/').reject(&:empty?)
14
+ non_param = segments.reject { |s| s.start_with?(':', '{') }
15
+ return gateway.name if non_param.empty?
16
+
17
+ return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && nested_resource?(segments)
18
+
19
+ non_param.first.gsub('-', '_')
20
+ end
21
+
22
+ def infer_action(route, _gateway)
23
+ return route.action.to_s if route.action
24
+
25
+ segments = route.path.split('/').reject(&:empty?)
26
+ verb = route.method
27
+
28
+ if route.singular_resource?
29
+ infer_singular_resource_action(verb)
30
+ elsif route.plural_resource?
31
+ infer_plural_resource_action(verb, segments)
32
+ else
33
+ infer_plain_action(verb, segments)
34
+ end
35
+ end
36
+
37
+ def infer_singular_resource_action(verb)
38
+ case verb
39
+ when 'GET' then 'show'
40
+ when 'PUT', 'PATCH' then 'update'
41
+ when 'DELETE' then 'destroy'
42
+ when 'POST' then 'create'
43
+ else 'show'
44
+ end
45
+ end
46
+
47
+ def infer_plural_resource_action(verb, segments)
48
+ has_id = segments.any? { |s| s.start_with?(':', '{') }
49
+ last_is_param = segments.last&.start_with?(':', '{')
50
+
51
+ if nested_resource?(segments)
52
+ child_idx = segments.rindex { |s| !s.start_with?(':', '{') }
53
+ has_child_id = child_idx && segments[(child_idx + 1)..]&.any? { |s| s.start_with?(':', '{') }
54
+ restful_action(verb, has_child_id || false)
55
+ else
56
+ restful_action(verb, has_id && last_is_param)
57
+ end
58
+ end
59
+
60
+ def infer_plain_action(verb, segments)
61
+ non_param = segments.reject { |s| s.start_with?(':', '{') }
62
+ has_id = segments.any? { |s| s.start_with?(':', '{') }
63
+ last_is_param = segments.last&.start_with?(':', '{')
64
+
65
+ if non_param.length <= 1 && !has_id
66
+ non_param.first&.gsub('-', '_') || 'index'
67
+ elsif non_param.length > 1
68
+ non_param.last.gsub('-', '_')
69
+ else
70
+ restful_action(verb, has_id && last_is_param)
71
+ end
72
+ end
73
+
74
+ def nested_resource?(segments)
75
+ segments.length >= 3 &&
76
+ !segments[0].start_with?(':', '{') &&
77
+ segments[1]&.start_with?(':', '{') &&
78
+ !segments[2]&.start_with?(':', '{')
79
+ end
80
+
81
+ def restful_action(verb, is_member)
82
+ case [verb, is_member]
83
+ when ['GET', false] then 'index'
84
+ when ['GET', true] then 'show'
85
+ when ['POST', false] then 'create'
86
+ when ['PUT', true], ['PATCH', true] then 'update'
87
+ when ['DELETE', true] then 'destroy'
88
+ else 'index'
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ class RoutesCommand
6
+ # Extracts schema model loading logic from RoutesCommand.
7
+ module SchemaLoader
8
+ private
9
+
10
+ def load_schema_models(routes_file)
11
+ schema_file = resolve_schema_file(routes_file)
12
+ return [] unless schema_file && File.exist?(schema_file)
13
+
14
+ Belt.instance_variable_set(:@application, nil)
15
+ begin
16
+ eval(File.read(schema_file), binding, schema_file) # rubocop:disable Security/Eval
17
+ rescue StandardError => e
18
+ warn "Warning: Failed to load schema file #{schema_file}: #{e.message}"
19
+ return []
20
+ end
21
+
22
+ schema = Belt.application.schema.to_h
23
+ build_models_from_schema(schema)
24
+ end
25
+
26
+ def resolve_schema_file(routes_file)
27
+ schema_file = @options[:schema_file]
28
+ unless schema_file
29
+ routes_dir = File.dirname(File.expand_path(routes_file))
30
+ schema_file = File.join(routes_dir, 'schema.tf.rb')
31
+ end
32
+ schema_file
33
+ end
34
+
35
+ def build_models_from_schema(schema)
36
+ models = []
37
+
38
+ (schema[:request_models] || {}).each_value do |model|
39
+ models << {
40
+ name: model[:name],
41
+ kind: 'request',
42
+ description: "Request model: #{model[:name]}",
43
+ properties: stringify_properties(model[:properties] || {}),
44
+ required: (model[:required] || []).map(&:to_s)
45
+ }
46
+ end
47
+
48
+ (schema[:response_models] || {}).each_value do |model|
49
+ (model[:contexts] || {}).each do |ctx_name, ctx|
50
+ models << {
51
+ name: "#{model[:name]}_#{ctx_name}_response",
52
+ kind: 'response',
53
+ description: "Response model: #{model[:name]} (#{ctx_name} context)",
54
+ properties: stringify_properties(ctx[:properties] || {}),
55
+ required: []
56
+ }
57
+ end
58
+ end
59
+
60
+ models
61
+ end
62
+
63
+ def stringify_properties(properties)
64
+ properties.each_with_object({}) do |(key, value), hash|
65
+ hash[key.to_s] = value.transform_keys(&:to_s)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end