belt 0.3.2 → 0.3.4
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 +6 -0
- data/lib/belt/action_router.rb +13 -2
- data/lib/belt/cli/explain_command.rb +110 -0
- data/lib/belt/cli/frontend_env_command.rb +1 -1
- data/lib/belt/cli/frontend_env_map.rb +3 -1
- data/lib/belt/cli/lambda_config_command.rb +7 -2
- data/lib/belt/cli/routes_command.rb +24 -0
- data/lib/belt/cli.rb +4 -1
- data/lib/belt/docs/backups.md +101 -0
- data/lib/belt/docs/console.md +65 -0
- data/lib/belt/docs/controllers.md +155 -0
- data/lib/belt/docs/deployment.md +141 -0
- data/lib/belt/docs/generators.md +128 -0
- data/lib/belt/docs/lambda_handler.md +105 -0
- data/lib/belt/docs/models.md +160 -0
- data/lib/belt/docs/observability.md +94 -0
- data/lib/belt/docs/plugins.md +94 -0
- data/lib/belt/docs/routing.md +138 -0
- data/lib/belt/docs/structure.md +93 -0
- data/lib/belt/route_dsl.rb +17 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/new_app/AGENTS.md.erb +140 -19
- data/lib/templates/new_app/config/routes.rb.erb +5 -2
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 33ab3ee829508580c32a99fd91f008cd09b3dd80e2502f22b03e7e4d5ff9d00a
|
|
4
|
+
data.tar.gz: 204c45931c8e038037f4d49781bbf43fff9396cd58e32d38d4cdb607fdccdfa7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f2f957782e6b03ef246167f46309a51a50cc286d3d4c1d8ba4411eba8a2ad558a2556986991848116519c5286bef5d57e5805307bbf314932580b82397beb43
|
|
7
|
+
data.tar.gz: 587671ca7188c4ff9476ee4568b3f7f513c05eb285088be3b7282a1b2096b4cc7f6a3c7d7f1e609e92e22e87db851b785422fd0ab4ea07e76ecd028cf6b87103
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.4
|
|
4
|
+
|
|
5
|
+
### Enhancement
|
|
6
|
+
|
|
7
|
+
- **Flexible config file extensions**: Lambda config files (`config/lambda/*.yml`) and frontend env maps (`frontend/env.yml`, `.belt/frontend_env.yml`) now also accept `.yaml` as a file extension. (#1124)
|
|
8
|
+
|
|
3
9
|
## 0.3.2
|
|
4
10
|
|
|
5
11
|
### Bug fix
|
data/lib/belt/action_router.rb
CHANGED
|
@@ -31,8 +31,15 @@ module Belt
|
|
|
31
31
|
route_info = find_route(method, match_path)
|
|
32
32
|
|
|
33
33
|
unless route_info
|
|
34
|
-
Belt
|
|
35
|
-
|
|
34
|
+
# Fallback: serve the Belt welcome page for GET / when no explicit root route exists.
|
|
35
|
+
# This handles the case where API Gateway delivers the request (e.g. via a catch-all
|
|
36
|
+
# integration or proxy route) but the route manifest doesn't include GET /.
|
|
37
|
+
if method == 'GET' && root_path?(match_path)
|
|
38
|
+
route_info = { verb: 'GET', pattern: '/', segments: [], controller: 'welcome', action: 'show' }
|
|
39
|
+
else
|
|
40
|
+
Belt::Observability::Logger.instance&.warn('Route not found', method: method, path: full_path)
|
|
41
|
+
return error_response('Not found', 404, event)
|
|
42
|
+
end
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
path_params = extract_path_params(route_info[:pattern], match_path)
|
|
@@ -60,6 +67,10 @@ module Belt
|
|
|
60
67
|
|
|
61
68
|
private
|
|
62
69
|
|
|
70
|
+
def root_path?(path)
|
|
71
|
+
path == '/' || path.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
def strip_gateway_prefix(path)
|
|
64
75
|
return '/' if path.nil?
|
|
65
76
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module CLI
|
|
5
|
+
class ExplainCommand
|
|
6
|
+
DOCS_DIR = File.expand_path('../docs', __dir__)
|
|
7
|
+
|
|
8
|
+
TOPICS = Dir.glob(File.join(DOCS_DIR, '*.md')).map do |path|
|
|
9
|
+
File.basename(path, '.md')
|
|
10
|
+
end.sort.freeze
|
|
11
|
+
|
|
12
|
+
ALIASES = {
|
|
13
|
+
'routes' => 'routing',
|
|
14
|
+
'route' => 'routing',
|
|
15
|
+
'router' => 'routing',
|
|
16
|
+
'controller' => 'controllers',
|
|
17
|
+
'model' => 'models',
|
|
18
|
+
'activeitem' => 'models',
|
|
19
|
+
'dynamodb' => 'models',
|
|
20
|
+
'deploy' => 'deployment',
|
|
21
|
+
'deploying' => 'deployment',
|
|
22
|
+
'terraform' => 'deployment',
|
|
23
|
+
'generate' => 'generators',
|
|
24
|
+
'generator' => 'generators',
|
|
25
|
+
'scaffold' => 'generators',
|
|
26
|
+
'handler' => 'lambda_handler',
|
|
27
|
+
'lambda' => 'lambda_handler',
|
|
28
|
+
'entry_point' => 'lambda_handler',
|
|
29
|
+
'entrypoint' => 'lambda_handler',
|
|
30
|
+
'project' => 'structure',
|
|
31
|
+
'layout' => 'structure',
|
|
32
|
+
'directory' => 'structure',
|
|
33
|
+
'logs' => 'observability',
|
|
34
|
+
'logging' => 'observability',
|
|
35
|
+
'metrics' => 'observability',
|
|
36
|
+
'backup' => 'backups',
|
|
37
|
+
'plugin' => 'plugins',
|
|
38
|
+
'irb' => 'console',
|
|
39
|
+
'repl' => 'console'
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
def self.run(args)
|
|
43
|
+
if args.empty? || args.include?('--help') || args.include?('-h')
|
|
44
|
+
puts usage
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
topic = resolve_topic(args.first)
|
|
49
|
+
|
|
50
|
+
if topic.nil?
|
|
51
|
+
puts "Unknown topic: #{args.first}\n\n"
|
|
52
|
+
puts 'Available topics:'
|
|
53
|
+
TOPICS.each { |t| puts " #{t}" }
|
|
54
|
+
puts "\nRun `belt explain <topic>` for details."
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
display_topic(topic)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.resolve_topic(input)
|
|
62
|
+
normalized = input.downcase.gsub('-', '_')
|
|
63
|
+
return normalized if TOPICS.include?(normalized)
|
|
64
|
+
return ALIASES[normalized] if ALIASES[normalized]
|
|
65
|
+
|
|
66
|
+
# Fuzzy match: find topics that start with or contain the input
|
|
67
|
+
match = TOPICS.find { |t| t.start_with?(normalized) }
|
|
68
|
+
match || TOPICS.find { |t| t.include?(normalized) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.display_topic(topic)
|
|
72
|
+
path = File.join(DOCS_DIR, "#{topic}.md")
|
|
73
|
+
content = File.read(path)
|
|
74
|
+
puts content
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.usage
|
|
78
|
+
topic_list = TOPICS.map { |t| " #{t}" }.join("\n")
|
|
79
|
+
|
|
80
|
+
<<~USAGE
|
|
81
|
+
Usage: belt explain <topic>
|
|
82
|
+
|
|
83
|
+
Display documentation for a Belt concept or feature.
|
|
84
|
+
|
|
85
|
+
Available topics:
|
|
86
|
+
#{topic_list}
|
|
87
|
+
|
|
88
|
+
Aliases:
|
|
89
|
+
routes, route, router → routing
|
|
90
|
+
controller → controllers
|
|
91
|
+
model, activeitem → models
|
|
92
|
+
deploy, terraform → deployment
|
|
93
|
+
generate, scaffold → generators
|
|
94
|
+
handler, lambda → lambda_handler
|
|
95
|
+
project, layout → structure
|
|
96
|
+
logs, logging, metrics → observability
|
|
97
|
+
backup → backups
|
|
98
|
+
plugin → plugins
|
|
99
|
+
irb, repl → console
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
belt explain routing
|
|
103
|
+
belt explain controllers
|
|
104
|
+
belt explain deploy
|
|
105
|
+
belt explain models
|
|
106
|
+
USAGE
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -36,7 +36,7 @@ module Belt
|
|
|
36
36
|
puts 'Usage: belt frontend env <environment>'
|
|
37
37
|
puts "\nWrites frontend/.env from terraform outputs using the env map."
|
|
38
38
|
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
39
|
-
puts "\nMap file (optional): frontend/env.yml or .belt/frontend_env.yml"
|
|
39
|
+
puts "\nMap file (optional): frontend/env.{yml,yaml} or .belt/frontend_env.{yml,yaml}"
|
|
40
40
|
puts 'Default without map: VITE_API_URL ← api_url'
|
|
41
41
|
puts "\nExamples:"
|
|
42
42
|
puts ' belt frontend env dev'
|
|
@@ -21,7 +21,9 @@ module Belt
|
|
|
21
21
|
class FrontendEnvMap
|
|
22
22
|
MAP_CANDIDATES = [
|
|
23
23
|
File.join('frontend', 'env.yml'),
|
|
24
|
-
File.join('
|
|
24
|
+
File.join('frontend', 'env.yaml'),
|
|
25
|
+
File.join('.belt', 'frontend_env.yml'),
|
|
26
|
+
File.join('.belt', 'frontend_env.yaml')
|
|
25
27
|
].freeze
|
|
26
28
|
|
|
27
29
|
DEFAULT_MAP = { 'VITE_API_URL' => 'api_url' }.freeze
|
|
@@ -146,8 +146,13 @@ module Belt
|
|
|
146
146
|
return {} unless Dir.exist?(config_dir)
|
|
147
147
|
|
|
148
148
|
configs = {}
|
|
149
|
-
Dir.glob(File.join(config_dir, '*.yml'))
|
|
150
|
-
|
|
149
|
+
files = Dir.glob(File.join(config_dir, '*.{yml,yaml}'))
|
|
150
|
+
# Sort so .yml comes before .yaml for the same basename (yml wins if both exist)
|
|
151
|
+
files.sort_by! { |f| [File.basename(f).sub(/\.ya?ml\z/, ''), f.end_with?('.yml') ? 0 : 1] }
|
|
152
|
+
files.each do |file|
|
|
153
|
+
name = File.basename(file).sub(/\.ya?ml\z/, '')
|
|
154
|
+
next if configs.key?(name) # .yml takes precedence
|
|
155
|
+
|
|
151
156
|
raw = YAML.safe_load_file(file, aliases: true) || {}
|
|
152
157
|
configs[name] = resolve_environment(raw, environment)
|
|
153
158
|
end
|
|
@@ -114,10 +114,34 @@ module Belt
|
|
|
114
114
|
gateway.routes.each do |route|
|
|
115
115
|
routes << build_route_hash(route, gateway)
|
|
116
116
|
end
|
|
117
|
+
inject_welcome_route!(routes, gateway) unless root_route?(gateway)
|
|
117
118
|
end
|
|
118
119
|
routes.sort_by { |r| route_specificity(r[:path], r[:verb]) }
|
|
119
120
|
end
|
|
120
121
|
|
|
122
|
+
# Ensure every gateway has a GET / route so the API Gateway resource is created.
|
|
123
|
+
# Without this, visiting the root URL returns a 404 at the gateway level before
|
|
124
|
+
# the request ever reaches Lambda.
|
|
125
|
+
def root_route?(gateway)
|
|
126
|
+
gateway.routes.any? { |r| r.method == 'GET' && normalize_path(r.path) == '/' }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def inject_welcome_route!(routes, gateway)
|
|
130
|
+
routes << {
|
|
131
|
+
name: 'root',
|
|
132
|
+
verb: 'GET',
|
|
133
|
+
path: '/',
|
|
134
|
+
gateway: gateway.name,
|
|
135
|
+
lambda: gateway.default_lambda.to_s,
|
|
136
|
+
controller: 'welcome',
|
|
137
|
+
action: 'show',
|
|
138
|
+
auth: 'none',
|
|
139
|
+
tables: [],
|
|
140
|
+
request_model: '',
|
|
141
|
+
response_model: ''
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
121
145
|
def build_route_hash(route, gateway)
|
|
122
146
|
hash = {
|
|
123
147
|
name: extract_route_name(route.path),
|
data/lib/belt/cli.rb
CHANGED
|
@@ -26,6 +26,7 @@ require_relative 'cli/console_command'
|
|
|
26
26
|
require_relative 'cli/logs_command'
|
|
27
27
|
require_relative 'cli/doctor_command'
|
|
28
28
|
require_relative 'cli/plugin_command'
|
|
29
|
+
require_relative 'cli/explain_command'
|
|
29
30
|
|
|
30
31
|
module Belt
|
|
31
32
|
module CLI
|
|
@@ -42,6 +43,7 @@ module Belt
|
|
|
42
43
|
'setup' => Belt::CLI::SetupCommand,
|
|
43
44
|
'doctor' => Belt::CLI::DoctorCommand,
|
|
44
45
|
'plugin' => Belt::CLI::PluginCommand,
|
|
46
|
+
'explain' => Belt::CLI::ExplainCommand,
|
|
45
47
|
'deploy' => Belt::CLI::DeployCommand,
|
|
46
48
|
'frontend' => Belt::CLI::FrontendEnvCommand,
|
|
47
49
|
%w[server s] => Belt::CLI::ServerCommand,
|
|
@@ -55,7 +57,7 @@ module Belt
|
|
|
55
57
|
TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
|
|
56
58
|
|
|
57
59
|
# Commands that can run without being inside a Belt project
|
|
58
|
-
STANDALONE_COMMANDS = %w[new version --version -v doctor].freeze
|
|
60
|
+
STANDALONE_COMMANDS = %w[new version --version -v doctor explain].freeze
|
|
59
61
|
|
|
60
62
|
def self.start(args)
|
|
61
63
|
command = args.shift
|
|
@@ -124,6 +126,7 @@ module Belt
|
|
|
124
126
|
setup frontend <env> Generate S3 + CloudFront infrastructure
|
|
125
127
|
doctor Check system dependencies and AWS config
|
|
126
128
|
plugin new <name> Scaffold a new Belt plugin gem
|
|
129
|
+
explain <topic> Explain a Belt concept (routing, models, …)
|
|
127
130
|
init [environment] <env> terraform init for environment
|
|
128
131
|
plan [environment] <env> terraform plan for environment
|
|
129
132
|
apply [environment] <env> terraform apply for environment
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Backups
|
|
2
|
+
|
|
3
|
+
Belt integrates automated pre-deploy backups into the deploy lifecycle.
|
|
4
|
+
When configured, `belt deploy` creates recovery points before applying changes.
|
|
5
|
+
|
|
6
|
+
## Quick Setup
|
|
7
|
+
|
|
8
|
+
Create `infrastructure/<env>/belt.rb`:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
Belt.configure do |config|
|
|
12
|
+
config.backups do
|
|
13
|
+
dynamodb :all
|
|
14
|
+
retention snapshots: 90
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then deploy normally — backups run automatically:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
belt deploy prod
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Simple Mode
|
|
26
|
+
|
|
27
|
+
For DynamoDB-only backups with defaults (all tables, 90-day retention):
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
Belt.configure do |config|
|
|
31
|
+
config.backups = true
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Full Configuration
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
Belt.configure do |config|
|
|
39
|
+
config.backups do
|
|
40
|
+
dynamodb :all # All tables: PITR check + on-demand snapshot
|
|
41
|
+
dynamodb :posts, :users # Or specific tables only
|
|
42
|
+
cognito :users, :pool_config # Export user list + pool settings to S3
|
|
43
|
+
s3 :legal_documents # Sync bucket to backup bucket
|
|
44
|
+
retention snapshots: 90, cognito: 10, s3: 10
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Backup Types
|
|
50
|
+
|
|
51
|
+
| Type | What It Does | Default Retention |
|
|
52
|
+
|------|-------------|-------------------|
|
|
53
|
+
| `dynamodb :all` | PITR verification + on-demand snapshot per table | 90 days |
|
|
54
|
+
| `dynamodb :table1, :table2` | Same, specific tables only | 90 days |
|
|
55
|
+
| `cognito :users` | Paginated user export → JSON in backup bucket | 10 copies |
|
|
56
|
+
| `cognito :pool_config` | Pool configuration export → JSON | 10 copies |
|
|
57
|
+
| `s3 :bucket_name` | Full sync to backup bucket | 10 copies |
|
|
58
|
+
|
|
59
|
+
## CLI Flags
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
belt deploy prod # normal deploy (runs backups first)
|
|
63
|
+
belt deploy prod --skip-backup # skip backup phase
|
|
64
|
+
belt deploy prod --backup-only # just create recovery point, don't deploy
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## How It Works
|
|
68
|
+
|
|
69
|
+
1. Creates backup bucket `<app-name>-backups-<env>` on first run (versioned, public access blocked)
|
|
70
|
+
2. Reads table names from `terraform output`
|
|
71
|
+
3. Verifies PITR is enabled on each DynamoDB table
|
|
72
|
+
4. Creates on-demand backup named `<table>-<timestamp>`
|
|
73
|
+
5. For Cognito/S3: exports to backup bucket under timestamped prefixes
|
|
74
|
+
6. Cleans up expired snapshots/copies beyond retention
|
|
75
|
+
|
|
76
|
+
## First Deploy
|
|
77
|
+
|
|
78
|
+
On a brand-new environment with no prior deploys, there are no Terraform outputs
|
|
79
|
+
to read table names from. Belt warns and skips the backup phase gracefully.
|
|
80
|
+
After the first successful deploy, backups run normally.
|
|
81
|
+
|
|
82
|
+
## DynamoDB Protection Defaults
|
|
83
|
+
|
|
84
|
+
All Belt-generated DynamoDB tables include:
|
|
85
|
+
- **PITR** (Point-in-Time Recovery) — enabled by default (35 days continuous)
|
|
86
|
+
- **Deletion protection** — enabled in prod, disabled in dev
|
|
87
|
+
|
|
88
|
+
## Skipping Backups in Dev
|
|
89
|
+
|
|
90
|
+
Don't create `infrastructure/dev01/belt.rb`, or omit the backups block:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
Belt.configure do |config|
|
|
94
|
+
# No backups block = no backups during deploy
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## See Also
|
|
99
|
+
|
|
100
|
+
- `belt explain deployment` — the full deploy lifecycle
|
|
101
|
+
- `belt deploy --help` — all deploy options
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Console
|
|
2
|
+
|
|
3
|
+
`belt console` (alias: `belt c`) starts an interactive Ruby session with your
|
|
4
|
+
application fully loaded — models, configuration, AWS clients, everything.
|
|
5
|
+
|
|
6
|
+
## Basic Usage
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
belt console # uses BELT_ENV or defaults to 'dev'
|
|
10
|
+
belt c prod # specify environment
|
|
11
|
+
belt c dev01 # any environment name
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What Gets Loaded
|
|
15
|
+
|
|
16
|
+
1. `lambda/config/environment.rb` — your app's boot file (AWS setup, models, libs)
|
|
17
|
+
2. IRB starts with `reload!` available
|
|
18
|
+
3. `.irbrc` from project root (optional console customization)
|
|
19
|
+
|
|
20
|
+
## Runner Mode
|
|
21
|
+
|
|
22
|
+
Execute a command and exit (useful for scripts/CI):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
belt c dev01 --run "Customer.first"
|
|
26
|
+
belt c prod --run "Post.count"
|
|
27
|
+
belt c dev01 --run "User.where(status: 'active', index: 'StatusIndex').count"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Production Safety
|
|
31
|
+
|
|
32
|
+
When the environment is `prod`, Belt shows a confirmation prompt before
|
|
33
|
+
starting the console. This prevents accidentally running destructive commands
|
|
34
|
+
against production data.
|
|
35
|
+
|
|
36
|
+
## Common Tasks
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# Find a record
|
|
40
|
+
post = Post.find("post-id-123")
|
|
41
|
+
|
|
42
|
+
# Query with index
|
|
43
|
+
users = User.where(status: "active", index: "StatusIndex")
|
|
44
|
+
|
|
45
|
+
# Create a record
|
|
46
|
+
Post.create!(title: "Test", body: "Hello", user_id: "u-123")
|
|
47
|
+
|
|
48
|
+
# Count records
|
|
49
|
+
Order.count
|
|
50
|
+
|
|
51
|
+
# Reload code changes
|
|
52
|
+
reload!
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Environment Resolution
|
|
56
|
+
|
|
57
|
+
Priority order:
|
|
58
|
+
1. Explicit argument: `belt c prod`
|
|
59
|
+
2. `BELT_ENV` environment variable
|
|
60
|
+
3. Default: `dev`
|
|
61
|
+
|
|
62
|
+
## See Also
|
|
63
|
+
|
|
64
|
+
- `belt explain models` — ActiveItem query methods
|
|
65
|
+
- `belt explain structure` — where environment.rb lives
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Controllers
|
|
2
|
+
|
|
3
|
+
Belt controllers inherit from `BeltController::Base` and handle HTTP requests
|
|
4
|
+
dispatched by `Belt::ActionRouter`. They provide callbacks, strong parameters,
|
|
5
|
+
response helpers, and error handling — similar to Rails ActionController.
|
|
6
|
+
|
|
7
|
+
## Basic Controller
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
module MyApp
|
|
11
|
+
class PostsController < ApplicationController
|
|
12
|
+
def index
|
|
13
|
+
@posts = Post.all
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
@post = Post.find(params["id"])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create
|
|
21
|
+
attrs = params.require(:post).permit(:title, :body).to_h
|
|
22
|
+
@post = Post.create!(attrs.merge(user_id: current_user_id))
|
|
23
|
+
response_status :created
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def destroy
|
|
27
|
+
Post.find(params["id"]).destroy
|
|
28
|
+
head :no_content
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Response Behavior
|
|
35
|
+
|
|
36
|
+
### Implicit Responses (default: JSON)
|
|
37
|
+
|
|
38
|
+
When an action sets instance variables and returns without calling a response
|
|
39
|
+
helper, Belt auto-serializes assigns into a JSON response:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
def index
|
|
43
|
+
@posts = Post.all # → { "posts": [...] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def show
|
|
47
|
+
@post = Post.find(params["id"]) # → { "post": {...} }
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Explicit Response Helpers
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
success_response({ id: "123", name: "Example" }) # 200 JSON
|
|
55
|
+
success_response({ id: "123" }, :created) # 201 JSON
|
|
56
|
+
error_response("Not found", :not_found) # 404 JSON
|
|
57
|
+
error_response("Nope", :unprocessable_entity) # 422 JSON
|
|
58
|
+
html_response("<h1>Hello</h1>") # 200 HTML
|
|
59
|
+
head :no_content # 204 empty
|
|
60
|
+
head :created # 201 empty
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Non-200 with Implicit Assigns
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
def create
|
|
67
|
+
@post = Post.create!(...)
|
|
68
|
+
response_status :created # → 201 + { "post": {...} }
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Default Format
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# Global (in config/environment.rb)
|
|
76
|
+
Belt.configure do |config|
|
|
77
|
+
config.default_format = :json # or :html
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Per-controller
|
|
81
|
+
class PagesController < ApplicationController
|
|
82
|
+
self.default_format = :html
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- `:json` — assigns become JSON body
|
|
87
|
+
- `:html` — renders `views/<controller>/<action>.html.erb`
|
|
88
|
+
|
|
89
|
+
## Callbacks
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
class ApplicationController < BeltController::Base
|
|
93
|
+
before_action :authenticate!
|
|
94
|
+
before_action :require_admin!, except: [:health]
|
|
95
|
+
skip_before_action :authenticate!, only: [:health]
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Callbacks run in definition order. `before_action` can halt the request by
|
|
100
|
+
calling a response helper (e.g., `error_response`).
|
|
101
|
+
|
|
102
|
+
## Strong Parameters
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
params.require(:user).permit(:name, :email, address: [:street, :city])
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `params` — merged hash of path parameters + parsed JSON body
|
|
109
|
+
- `require(:key)` — raises if key missing
|
|
110
|
+
- `permit(:field1, :field2)` — whitelists allowed fields
|
|
111
|
+
- Nested: `permit(:name, address: [:street, :city])`
|
|
112
|
+
|
|
113
|
+
## Error Handling
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
class ApplicationController < BeltController::Base
|
|
117
|
+
rescue_from ActiveItem::RecordNotFound, with: :not_found
|
|
118
|
+
rescue_from MyCustomError, with: :handle_custom
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def not_found(exception, _context = {})
|
|
123
|
+
error_response(exception.message, :not_found)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def handle_custom(exception, _context = {})
|
|
127
|
+
error_response(exception.message, :unprocessable_entity)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Controller Discovery
|
|
133
|
+
|
|
134
|
+
Belt resolves controllers by:
|
|
135
|
+
1. Checking the app's namespace module (e.g., `MyApp::PostsController`)
|
|
136
|
+
2. Searching `Belt.all_controller_paths`
|
|
137
|
+
|
|
138
|
+
No manual registration required. Controllers are auto-discovered from the
|
|
139
|
+
`lambda/controllers/` directory.
|
|
140
|
+
|
|
141
|
+
## CORS
|
|
142
|
+
|
|
143
|
+
CORS headers are handled automatically by `Belt::LambdaHandler`. Controllers
|
|
144
|
+
don't need to set them manually. Configure allowed origins via environment
|
|
145
|
+
variables:
|
|
146
|
+
|
|
147
|
+
- `CORS_ALLOWED_ORIGINS` — comma-separated origins
|
|
148
|
+
- `CUSTOMER_APP_DOMAIN` — primary app domain
|
|
149
|
+
- `OPS_APP_DOMAIN` — internal tools domain
|
|
150
|
+
|
|
151
|
+
## See Also
|
|
152
|
+
|
|
153
|
+
- `belt explain routing` — how requests reach controllers
|
|
154
|
+
- `belt explain models` — ActiveItem ORM
|
|
155
|
+
- `belt explain parameters` — strong parameters in detail
|