belt 0.1.0 → 0.1.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: d587a60fbaef9f5755a1746b766d5fdc6e6bf6efbc34ee58e3901b0324fbc199
4
- data.tar.gz: ac3bca34eb3552cf18693605b4c42ba11d11ea1d5437d201b6db838443951c28
3
+ metadata.gz: 9236fac00b7efcb43f17c02fb347626f768cf7dd5d6c1d2162470176043cdfde
4
+ data.tar.gz: d7998c44bfcdfa42eb4d22acca3fabf62e0005753b355f4112bbdff108682527
5
5
  SHA512:
6
- metadata.gz: fdcd4a4c7efbce1ef89e394f86f3b3252307f4e4d1462ff8582bd0de6fb5ce9d3e7d34cceacc43dd3d50c3165ef1e7f01592b220dcddeb246d81c371fcb59281
7
- data.tar.gz: 8ba38580f372f91d188aad8fc843435dc7f35ef90b7142027f9201317761f4de231d310ab58f5f5536400d6060925d425fcc8d208716381048f96bb6db15b574
6
+ metadata.gz: 3ec40527e1d14fe89e8e5f3dc412e803354630126d40e9bed1a8c1dc32703ecd5a1fbffec1690ff0262ee2d1ece1895751441fc554f2cc38b86cb1d36e8b4ea8
7
+ data.tar.gz: 3eaff9879994c9d2b7c3eb6b454a833bc4ae433bd544ac0c8fa3578045de96263a2871915b60d8707eaed8b512e497c88125554b45068d597d19d612a6b87209
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
 
@@ -129,7 +128,7 @@ terraform {
129
128
  Define routes in `infrastructure/routes.tf.rb`:
130
129
 
131
130
  ```ruby
132
- TerraDispatch.routes.draw do
131
+ Belt.application.routes.draw do
133
132
  namespace :api do
134
133
  resources :posts, only: [:index, :show, :create]
135
134
  end
@@ -139,7 +138,7 @@ end
139
138
  Define tables in `infrastructure/schema.tf.rb`:
140
139
 
141
140
  ```ruby
142
- TerraDispatch.schema.define do
141
+ Belt.application.schema.define do
143
142
  model :post do
144
143
  partition_key :id, :string
145
144
  global_secondary_index :UserIndex, partition_key: :user_id
@@ -201,80 +200,180 @@ error_response("Not found", 404) # 404 JSON error
201
200
  html_response("<h1>Hello</h1>") # 200 HTML with CORS
202
201
  ```
203
202
 
204
- ## Holsters (Belt's Engines)
203
+ ## Controller Discovery
205
204
 
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.
205
+ 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
206
 
208
- ### Creating a Holster
207
+ ## Belt::Observability
209
208
 
210
- In your gem, subclass `Belt::Holster`:
209
+ Belt provides global `Belt::Observability::Logger` and `Belt::Observability::Metrics` facades that are set automatically by `Belt::LambdaHandler`. Access them from anywhere:
211
210
 
212
211
  ```ruby
213
- # lib/s3arch/holster.rb
214
- module S3arch
215
- class Holster < Belt::Holster
216
- end
217
- end
212
+ Belt::Observability::Logger.info("Something happened", user_id: "123")
213
+ Belt::Observability::Metrics.track_event("OrderCreated", model: "Order")
214
+ ```
215
+
216
+ ## Environment Variables
217
+
218
+ | Variable | Purpose |
219
+ |----------|---------|
220
+ | `ENVIRONMENT` | Controls verbose error responses (`dev*`, `local`, `test`) |
221
+ | `BELT_METRICS_NAMESPACE` | CloudWatch metrics namespace (default: `Belt`) |
222
+ | `ACTION` | Service name for logging (falls back to function name) |
223
+ | `ERROR_NOTIFICATION_TOPIC_ARN` | SNS topic for error alerts |
224
+ | `CORS_ALLOWED_ORIGINS` | Comma-separated origins (overrides domain vars) |
225
+ | `CUSTOMER_APP_DOMAIN` | Primary app domain for CORS |
226
+ | `OPS_APP_DOMAIN` | Internal tools domain for CORS |
227
+
228
+ ## CLI
229
+
230
+ Belt includes a command-line interface for project management.
231
+
232
+ ### `belt routes`
233
+
234
+ Display route definitions from your `infrastructure/routes.tf.rb`. This is the primary way to inspect what endpoints your app exposes.
235
+
236
+ ```bash
237
+ belt routes
218
238
  ```
219
239
 
220
- That's it. Belt discovers all `Holster` subclasses at boot. By convention, it expects:
240
+ Output (single namespace):
221
241
 
222
242
  ```
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
243
+ VERB PATH CONTROLLER#ACTION
244
+ ------------------------------------------------------------------
245
+ GET /posts posts#index
246
+ GET /posts/{post_id} posts#show
247
+ POST /posts posts#create
248
+ DELETE /posts/{post_id} posts#destroy
230
249
  ```
231
250
 
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).
251
+ When multiple namespaces (API Gateways) exist, GATEWAY and LAMBDA columns are added automatically:
233
252
 
234
- ### Customizing Paths
253
+ ```
254
+ VERB PATH GATEWAY LAMBDA CONTROLLER#ACTION
255
+ ---------------------------------------------------------------
256
+ GET /posts blog blog posts#index
257
+ POST /posts blog blog posts#create
258
+ GET /posts ops ops posts#index
259
+ POST /posts ops ops posts#create
260
+ ```
235
261
 
236
- If your gem uses a different layout, override any path:
262
+ #### Options
237
263
 
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
264
+ | Flag | Description |
265
+ |------|-------------|
266
+ | `-g, --grep PATTERN` | Filter routes matching pattern (case-insensitive, matches verb, path, gateway, lambda, controller, or action) |
267
+ | `-f, --format FORMAT` | Output format: `concise` (default) or `json` |
268
+ | `--namespace NAMESPACE` | Generate Ruby route files for NAMESPACE (or "all") |
269
+ | `--output-dir DIR` | Output directory for generated Ruby files (default: `lambda/lib/routes/`) |
270
+ | `--schema FILE` | Path to `schema.tf.rb` for model definitions (default: same directory as routes file) |
271
+ | `--tables-file FILE` | Path to Terraform file with `aws_dynamodb_table` resources for table inference |
272
+ | `-h, --help` | Show help |
273
+
274
+ #### Examples
275
+
276
+ ```bash
277
+ # Filter routes by pattern
278
+ belt routes -g posts
279
+
280
+ # JSON output (for tooling/CI)
281
+ belt routes -f json
282
+
283
+ # Generate Ruby route constant for the "api" namespace
284
+ belt routes --namespace api
285
+
286
+ # Generate to a custom directory
287
+ belt routes --namespace api --output-dir lib/routes
288
+
289
+ # Include schema models in JSON output
290
+ belt routes -f json --schema infrastructure/schema.tf.rb
291
+
292
+ # Infer DynamoDB table access from Terraform
293
+ belt routes -f json --tables-file infrastructure/main.tf
245
294
  ```
246
295
 
247
- ### How Belt Uses Holsters
296
+ #### JSON Output
297
+
298
+ With `--format json`, the output includes a `routes` array and optionally a `models` array (when a schema file is found):
299
+
300
+ ```json
301
+ {
302
+ "routes": [
303
+ {
304
+ "name": "posts",
305
+ "verb": "GET",
306
+ "path": "/posts",
307
+ "gateway": "api",
308
+ "lambda": "api",
309
+ "controller": "posts",
310
+ "action": "index",
311
+ "auth": "cognito",
312
+ "tables": ["posts"],
313
+ "request_model": "",
314
+ "response_model": ""
315
+ }
316
+ ],
317
+ "models": [
318
+ {
319
+ "name": "CreatePost",
320
+ "kind": "request",
321
+ "description": "Request model: CreatePost",
322
+ "properties": {
323
+ "title": { "type": "string" },
324
+ "body": { "type": "string" }
325
+ },
326
+ "required": ["title"]
327
+ }
328
+ ]
329
+ }
330
+ ```
248
331
 
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
332
+ #### Ruby Output
253
333
 
254
- ## Controller Discovery
334
+ With `--namespace NAMESPACE`, Belt generates a frozen Ruby constant file at `lambda/lib/routes/<namespace>_routes.rb`:
255
335
 
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.
336
+ ```ruby
337
+ # frozen_string_literal: true
338
+
339
+ # Auto-generated by: belt routes --namespace api
340
+ # Do not edit manually
341
+
342
+ module Routes
343
+ API = [
344
+ {
345
+ verb: "GET",
346
+ path: "/posts",
347
+ gateway: "api",
348
+ lambda: "api",
349
+ controller: "posts",
350
+ action: "index",
351
+ auth: "cognito",
352
+ tables: ["posts"]
353
+ }
354
+ ].freeze
355
+ end
356
+ ```
257
357
 
258
- ## Belt::Observability
358
+ This is used by `Belt::ActionRouter` at runtime for request routing.
259
359
 
260
- Belt provides global `Belt::Observability::Logger` and `Belt::Observability::Metrics` facades that are set automatically by `Belt::LambdaHandler`. Access them from anywhere:
360
+ #### Route File Location
361
+
362
+ The command expects `infrastructure/routes.tf.rb` in the current working directory. Routes are defined using the same DSL as the Belt Terraform provider:
261
363
 
262
364
  ```ruby
263
- Belt::Observability::Logger.info("Something happened", user_id: "123")
264
- Belt::Observability::Metrics.track_event("OrderCreated", model: "Order")
365
+ Belt.application.routes.draw do
366
+ namespace :api do
367
+ resources :posts, only: [:index, :show, :create, :destroy]
368
+ resource :profile, only: [:show, :update]
369
+ get "health", action: :health
370
+ end
371
+ end
265
372
  ```
266
373
 
267
- ## Environment Variables
374
+ #### Table Inference
268
375
 
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 |
376
+ 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
377
 
279
378
  ## License
280
379
 
@@ -79,6 +79,7 @@ module Belt
79
79
  def files
80
80
  {
81
81
  'Gemfile.erb' => "#{@app_name}/Gemfile",
82
+ 'Rakefile.erb' => "#{@app_name}/Rakefile",
82
83
  'lambda/Gemfile.erb' => "#{@app_name}/lambda/Gemfile",
83
84
  'lambda/api.rb.erb' => "#{@app_name}/lambda/#{@app_name}.rb",
84
85
  'lambda/models/application_record.rb.erb' => "#{@app_name}/lambda/models/application_record.rb",
@@ -0,0 +1,100 @@
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
+ if route.resource?
20
+ non_param.first.gsub('-', '_')
21
+ elsif non_param.length == 1 && segments.length == 1
22
+ route.lambda.to_s == gateway.name.to_s ? gateway.name.to_s : route.lambda.to_s
23
+ else
24
+ non_param.first.gsub('-', '_')
25
+ end
26
+ end
27
+
28
+ def infer_action(route, _gateway)
29
+ return route.action.to_s if route.action
30
+
31
+ segments = route.path.split('/').reject(&:empty?)
32
+ verb = route.method
33
+
34
+ if route.singular_resource?
35
+ infer_singular_resource_action(verb)
36
+ elsif route.plural_resource?
37
+ infer_plural_resource_action(verb, segments)
38
+ else
39
+ infer_plain_action(verb, segments)
40
+ end
41
+ end
42
+
43
+ def infer_singular_resource_action(verb)
44
+ case verb
45
+ when 'GET' then 'show'
46
+ when 'PUT', 'PATCH' then 'update'
47
+ when 'DELETE' then 'destroy'
48
+ when 'POST' then 'create'
49
+ else 'show'
50
+ end
51
+ end
52
+
53
+ def infer_plural_resource_action(verb, segments)
54
+ has_id = segments.any? { |s| s.start_with?(':', '{') }
55
+ last_is_param = segments.last&.start_with?(':', '{')
56
+
57
+ if nested_resource?(segments)
58
+ child_idx = segments.rindex { |s| !s.start_with?(':', '{') }
59
+ has_child_id = child_idx && segments[(child_idx + 1)..]&.any? { |s| s.start_with?(':', '{') }
60
+ restful_action(verb, has_child_id || false)
61
+ else
62
+ restful_action(verb, has_id && last_is_param)
63
+ end
64
+ end
65
+
66
+ def infer_plain_action(verb, segments)
67
+ non_param = segments.reject { |s| s.start_with?(':', '{') }
68
+ has_id = segments.any? { |s| s.start_with?(':', '{') }
69
+ last_is_param = segments.last&.start_with?(':', '{')
70
+
71
+ if non_param.length <= 1 && !has_id
72
+ non_param.first&.gsub('-', '_') || 'index'
73
+ elsif non_param.length > 1
74
+ non_param.last.gsub('-', '_')
75
+ else
76
+ restful_action(verb, has_id && last_is_param)
77
+ end
78
+ end
79
+
80
+ def nested_resource?(segments)
81
+ segments.length >= 3 &&
82
+ !segments[0].start_with?(':', '{') &&
83
+ segments[1]&.start_with?(':', '{') &&
84
+ !segments[2]&.start_with?(':', '{')
85
+ end
86
+
87
+ def restful_action(verb, is_member)
88
+ case [verb, is_member]
89
+ when ['GET', false] then 'index'
90
+ when ['GET', true] then 'show'
91
+ when ['POST', false] then 'create'
92
+ when ['PUT', true], ['PATCH', true] then 'update'
93
+ when ['DELETE', true] then 'destroy'
94
+ else 'index'
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ 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