belt 0.2.9 → 0.2.11
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 +119 -0
- data/README.md +217 -8
- data/lib/belt/action_router.rb +33 -5
- data/lib/belt/assets/welcome.css +119 -47
- data/lib/belt/cli/bucket_security.rb +10 -4
- data/lib/belt/cli/deploy_command.rb +48 -0
- data/lib/belt/cli/destroy_command.rb +119 -10
- data/lib/belt/cli/doctor_command.rb +208 -0
- data/lib/belt/cli/environment_command.rb +38 -6
- data/lib/belt/cli/frontend_command.rb +25 -12
- data/lib/belt/cli/frontend_setup_command.rb +41 -0
- data/lib/belt/cli/new_command.rb +106 -38
- data/lib/belt/cli/path_gem_materializer.rb +141 -0
- data/lib/belt/cli/plugin_command.rb +221 -0
- data/lib/belt/cli/setup_command.rb +35 -10
- data/lib/belt/cli.rb +8 -0
- data/lib/belt/configuration.rb +30 -0
- data/lib/belt/controllers/welcome_controller.rb +69 -2
- data/lib/belt/helpers/cors_origin.rb +19 -2
- data/lib/belt/helpers/response.rb +48 -6
- data/lib/belt/http_status.rb +89 -0
- data/lib/belt/lambda_handler.rb +14 -5
- data/lib/belt/rendering.rb +1 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt/views/welcome/show.html.erb +31 -31
- data/lib/belt.rb +16 -0
- data/lib/belt_controller/base.rb +24 -1
- data/lib/belt_controller/implicit_response.rb +104 -0
- data/lib/templates/environment/backend.tf.erb +1 -1
- data/lib/templates/frontend/react/src/index.css +4 -4
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
- data/lib/templates/module/main.tf.erb +13 -3
- data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
- data/lib/templates/plugin/AGENTS.md.erb +135 -0
- data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
- data/lib/templates/plugin/Gemfile.erb +11 -0
- data/lib/templates/plugin/LICENSE.erb +21 -0
- data/lib/templates/plugin/README.md.erb +63 -0
- data/lib/templates/plugin/Rakefile.erb +7 -0
- data/lib/templates/plugin/gemspec.erb +26 -0
- data/lib/templates/plugin/gitignore.erb +11 -0
- data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
- data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
- data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
- data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
- data/lib/templates/plugin/lib/entry.rb.erb +3 -0
- data/lib/templates/plugin/rspec.erb +3 -0
- data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
- data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
- metadata +37 -1
|
@@ -38,11 +38,12 @@ module Belt
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def initialize(args = [])
|
|
41
|
+
def initialize(args = [], quiet: false)
|
|
42
42
|
@app_name = detect_app_name
|
|
43
43
|
@env_name = nil
|
|
44
44
|
@custom_bucket = nil
|
|
45
45
|
@select_mode = false
|
|
46
|
+
@quiet = quiet
|
|
46
47
|
|
|
47
48
|
parse_args(args)
|
|
48
49
|
|
|
@@ -67,7 +68,7 @@ module Belt
|
|
|
67
68
|
@bucket_name = interactive_bucket_selection if @select_mode
|
|
68
69
|
setup_or_verify_bucket
|
|
69
70
|
apply_lifecycle(@bucket_name)
|
|
70
|
-
|
|
71
|
+
say ' ensure lifecycle rules (90-day noncurrent expiration)'
|
|
71
72
|
update_backend_config
|
|
72
73
|
print_success_message
|
|
73
74
|
end
|
|
@@ -81,12 +82,12 @@ module Belt
|
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
def verify_existing_bucket
|
|
84
|
-
|
|
85
|
+
say "Found existing bucket: #{@bucket_name}"
|
|
85
86
|
audit = audit_bucket_security(@bucket_name)
|
|
86
|
-
print_security_audit(audit)
|
|
87
|
+
print_security_audit(audit) unless @quiet
|
|
87
88
|
|
|
88
89
|
if audit.values.all?
|
|
89
|
-
|
|
90
|
+
say "\n✓ Bucket '#{@bucket_name}' passes all security checks"
|
|
90
91
|
else
|
|
91
92
|
prompt_and_harden(audit)
|
|
92
93
|
end
|
|
@@ -94,6 +95,12 @@ module Belt
|
|
|
94
95
|
|
|
95
96
|
def prompt_and_harden(audit)
|
|
96
97
|
puts "\n⚠ Bucket '#{@bucket_name}' has security issues."
|
|
98
|
+
# Auto-harden during belt new (quiet); interactive otherwise
|
|
99
|
+
if @quiet
|
|
100
|
+
harden_bucket(@bucket_name, audit)
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
97
104
|
print "\nApply security hardening? [Y/n] "
|
|
98
105
|
response = $stdin.gets&.strip&.downcase
|
|
99
106
|
if response.nil? || response.empty? || response == 'y'
|
|
@@ -105,13 +112,15 @@ module Belt
|
|
|
105
112
|
end
|
|
106
113
|
|
|
107
114
|
def create_new_bucket
|
|
108
|
-
|
|
115
|
+
say "Creating state bucket: #{@bucket_name} (#{@region})"
|
|
109
116
|
create_bucket(@bucket_name)
|
|
110
|
-
|
|
117
|
+
say " create s3://#{@bucket_name}"
|
|
111
118
|
harden_bucket(@bucket_name, {})
|
|
112
119
|
end
|
|
113
120
|
|
|
114
121
|
def print_success_message
|
|
122
|
+
return if @quiet
|
|
123
|
+
|
|
115
124
|
puts "\n✓ State bucket '#{@bucket_name}' is ready!"
|
|
116
125
|
if @env_name
|
|
117
126
|
puts "\n cd infrastructure/#{@env_name} && terraform init"
|
|
@@ -120,6 +129,9 @@ module Belt
|
|
|
120
129
|
end
|
|
121
130
|
end
|
|
122
131
|
|
|
132
|
+
# Expose for quiet callers (e.g. belt new) that print their own summary
|
|
133
|
+
attr_reader :bucket_name
|
|
134
|
+
|
|
123
135
|
private
|
|
124
136
|
|
|
125
137
|
def parse_args(args)
|
|
@@ -138,8 +150,15 @@ module Belt
|
|
|
138
150
|
end
|
|
139
151
|
end
|
|
140
152
|
|
|
153
|
+
# One shared state bucket per AWS account (all belt apps use it).
|
|
154
|
+
# Account ID keeps the name globally unique — S3 names are a global namespace.
|
|
155
|
+
# Pattern: belt-terraform-state-<account_id>
|
|
141
156
|
def resolve_bucket_name
|
|
142
|
-
@custom_bucket
|
|
157
|
+
return @custom_bucket if @custom_bucket
|
|
158
|
+
return "belt-terraform-state-#{@aws_account_id}" if @aws_account_id
|
|
159
|
+
|
|
160
|
+
# Placeholder until credentials are available (belt setup state re-resolves)
|
|
161
|
+
'belt-terraform-state'
|
|
143
162
|
end
|
|
144
163
|
|
|
145
164
|
# --- Interactive selection ---
|
|
@@ -238,9 +257,11 @@ module Belt
|
|
|
238
257
|
end
|
|
239
258
|
|
|
240
259
|
def run!(*args)
|
|
241
|
-
|
|
260
|
+
output, status = Open3.capture2e(*args)
|
|
261
|
+
return if status.success?
|
|
242
262
|
|
|
243
263
|
puts "\n✗ Command failed: #{args.shelljoin}"
|
|
264
|
+
puts output.strip unless output.strip.empty?
|
|
244
265
|
exit 1
|
|
245
266
|
end
|
|
246
267
|
|
|
@@ -249,6 +270,10 @@ module Belt
|
|
|
249
270
|
status.success? ? output : nil
|
|
250
271
|
end
|
|
251
272
|
|
|
273
|
+
def say(message)
|
|
274
|
+
puts message unless @quiet
|
|
275
|
+
end
|
|
276
|
+
|
|
252
277
|
# --- Backend config ---
|
|
253
278
|
|
|
254
279
|
def update_backend_config
|
|
@@ -268,7 +293,7 @@ module Belt
|
|
|
268
293
|
updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@bucket_name}\"")
|
|
269
294
|
if updated != content
|
|
270
295
|
File.write(backend_file, updated)
|
|
271
|
-
|
|
296
|
+
say " update #{backend_file} → bucket = \"#{@bucket_name}\""
|
|
272
297
|
end
|
|
273
298
|
end
|
|
274
299
|
end
|
data/lib/belt/cli.rb
CHANGED
|
@@ -21,6 +21,8 @@ require_relative 'cli/routes_command'
|
|
|
21
21
|
require_relative 'cli/lambda_config_command'
|
|
22
22
|
require_relative 'cli/tasks_command'
|
|
23
23
|
require_relative 'cli/console_command'
|
|
24
|
+
require_relative 'cli/doctor_command'
|
|
25
|
+
require_relative 'cli/plugin_command'
|
|
24
26
|
|
|
25
27
|
module Belt
|
|
26
28
|
module CLI
|
|
@@ -33,6 +35,8 @@ module Belt
|
|
|
33
35
|
%w[console c] => Belt::CLI::ConsoleCommand,
|
|
34
36
|
%w[tasks --tasks -T] => Belt::CLI::TasksCommand,
|
|
35
37
|
'setup' => Belt::CLI::SetupCommand,
|
|
38
|
+
'doctor' => Belt::CLI::DoctorCommand,
|
|
39
|
+
'plugin' => Belt::CLI::PluginCommand,
|
|
36
40
|
'deploy' => Belt::CLI::DeployCommand,
|
|
37
41
|
'frontend' => Belt::CLI::FrontendEnvCommand,
|
|
38
42
|
%w[server s] => Belt::CLI::ServerCommand,
|
|
@@ -114,6 +118,8 @@ module Belt
|
|
|
114
118
|
setup state Create/select S3 state bucket
|
|
115
119
|
setup tables <env> Generate DynamoDB tables from schema
|
|
116
120
|
setup frontend <env> Generate S3 + CloudFront infrastructure
|
|
121
|
+
doctor Check system dependencies and AWS config
|
|
122
|
+
plugin new <name> Scaffold a new Belt plugin gem
|
|
117
123
|
init [environment] <env> terraform init for environment
|
|
118
124
|
plan [environment] <env> terraform plan for environment
|
|
119
125
|
apply [environment] <env> terraform apply for environment
|
|
@@ -133,6 +139,7 @@ module Belt
|
|
|
133
139
|
|
|
134
140
|
Examples:
|
|
135
141
|
belt new blog --frontend react
|
|
142
|
+
belt new blog --frontend react -v # list every created file
|
|
136
143
|
belt generate scaffold post title:string content:text status:string
|
|
137
144
|
belt destroy scaffold post
|
|
138
145
|
belt generate frontend react
|
|
@@ -145,6 +152,7 @@ module Belt
|
|
|
145
152
|
belt apply wups
|
|
146
153
|
belt tasks # list all rake tasks
|
|
147
154
|
belt lambda:build_layer # run a rake task directly
|
|
155
|
+
belt plugin new messaging # scaffold a belt-messaging style plugin gem
|
|
148
156
|
USAGE
|
|
149
157
|
end
|
|
150
158
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
# Runtime configuration for Belt apps (set in lambda/config/environment.rb).
|
|
5
|
+
#
|
|
6
|
+
# Belt.configure do |config|
|
|
7
|
+
# config.default_format = :json # or :html
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# Note: infrastructure/<env>/belt.rb uses a separate sandboxed DSL for CLI
|
|
11
|
+
# deploy/backup settings — it does not share this object.
|
|
12
|
+
class Configuration
|
|
13
|
+
VALID_FORMATS = %i[json html].freeze
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@default_format = :json
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :default_format
|
|
20
|
+
|
|
21
|
+
def default_format=(value)
|
|
22
|
+
format = value.to_sym
|
|
23
|
+
unless VALID_FORMATS.include?(format)
|
|
24
|
+
raise ArgumentError, "default_format must be :json or :html (got #{value.inspect})"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@default_format = format
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -6,23 +6,90 @@ module Belt
|
|
|
6
6
|
# Gem-embedded welcome controller that serves the default "it works!" page.
|
|
7
7
|
# Resolved via the ActionRouter's Belt:: namespace fallback when no app-defined
|
|
8
8
|
# WelcomeController exists. Gets replaced once the user defines their own root route.
|
|
9
|
+
#
|
|
10
|
+
# Dual format:
|
|
11
|
+
# - Browser / no Accept preference → HTML hero + stack check
|
|
12
|
+
# - Accept: application/json (SPA / apiClient) → JSON payload for the React shell
|
|
13
|
+
#
|
|
14
|
+
# The hero image (lib/belt/assets/belt-default.jpg) is the single source of truth —
|
|
15
|
+
# HTML embeds it, and JSON returns the same bytes as a data URI so the SPA matches.
|
|
9
16
|
class WelcomeController < BeltController::Base
|
|
10
17
|
skip_before_action :authenticate!
|
|
11
18
|
|
|
12
19
|
ASSETS_DIR = File.expand_path('../assets', __dir__)
|
|
20
|
+
# Scaffold gives model + controller + routes + schema (not model-only).
|
|
21
|
+
SCAFFOLD_HINT_CMD = 'belt generate scaffold Post title content:text'
|
|
13
22
|
|
|
14
23
|
def show
|
|
15
24
|
@title = ENV.fetch('WELCOME_TITLE', 'Welcome to Belt')
|
|
16
25
|
@subtitle = ENV.fetch('WELCOME_SUBTITLE', 'API Gateway → Lambda → DynamoDB — all connected.')
|
|
17
|
-
@css = welcome_css
|
|
18
|
-
@background_image = background_image_base64
|
|
19
26
|
@dynamodb_connected = dynamodb_connected?
|
|
27
|
+
@scaffold_hint_cmd = SCAFFOLD_HINT_CMD
|
|
28
|
+
|
|
29
|
+
return success_response(welcome_payload) if wants_json?
|
|
20
30
|
|
|
31
|
+
@css = welcome_css
|
|
32
|
+
@background_image = background_image_base64
|
|
21
33
|
render
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
private
|
|
25
37
|
|
|
38
|
+
def welcome_payload
|
|
39
|
+
{
|
|
40
|
+
title: @title,
|
|
41
|
+
subtitle: @subtitle,
|
|
42
|
+
# Same gem asset as the HTML welcome page — edit belt-default.jpg once.
|
|
43
|
+
background_image: "data:image/jpeg;base64,#{background_image_base64}",
|
|
44
|
+
stack: {
|
|
45
|
+
api_gateway: true,
|
|
46
|
+
lambda: true,
|
|
47
|
+
dynamodb: @dynamodb_connected
|
|
48
|
+
},
|
|
49
|
+
# One tip only (was duplicated as dynamodb_hint + first next_step).
|
|
50
|
+
next_steps: [
|
|
51
|
+
"Scaffold a resource: #{SCAFFOLD_HINT_CMD}",
|
|
52
|
+
'Deploy: belt deploy',
|
|
53
|
+
'This page will be replaced once you define your own root route.'
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# SPA apiClient and fetch(..., { headers: { Accept: "application/json" } })
|
|
59
|
+
def wants_json?
|
|
60
|
+
return true if params['format'].to_s == 'json'
|
|
61
|
+
|
|
62
|
+
accept = header_value('Accept') || header_value('accept') || ''
|
|
63
|
+
return false if accept.empty?
|
|
64
|
+
|
|
65
|
+
# Prefer JSON when it's listed and not beaten by an explicit HTML preference
|
|
66
|
+
json_q = accept_quality(accept, 'application/json')
|
|
67
|
+
html_q = accept_quality(accept, 'text/html')
|
|
68
|
+
json_q.positive? && json_q >= html_q
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def header_value(name)
|
|
72
|
+
headers = event.is_a?(Hash) ? event['headers'] : nil
|
|
73
|
+
return nil unless headers.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
headers[name] || headers[name.downcase] || headers[name.split('-').map(&:capitalize).join('-')]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def accept_quality(accept, media_type)
|
|
79
|
+
accept.split(',').each do |part|
|
|
80
|
+
type, *params = part.strip.split(';').map(&:strip)
|
|
81
|
+
# Exact type only — do not treat */* as JSON (curl/browsers send that by default)
|
|
82
|
+
next unless type == media_type
|
|
83
|
+
|
|
84
|
+
q = 1.0
|
|
85
|
+
params.each do |p|
|
|
86
|
+
q = p.split('=', 2).last.to_f if p.start_with?('q=')
|
|
87
|
+
end
|
|
88
|
+
return q
|
|
89
|
+
end
|
|
90
|
+
0.0
|
|
91
|
+
end
|
|
92
|
+
|
|
26
93
|
def dynamodb_connected?
|
|
27
94
|
return false unless defined?(Aws::DynamoDB::Client)
|
|
28
95
|
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
module Belt
|
|
4
4
|
module Helpers
|
|
5
5
|
module CorsOrigin
|
|
6
|
+
# Maximum length for an Origin header to prevent ReDoS or memory abuse.
|
|
7
|
+
MAX_ORIGIN_LENGTH = 253
|
|
8
|
+
|
|
6
9
|
def self.resolve_origin(request_origin)
|
|
7
10
|
allowed = allowed_origins
|
|
8
11
|
return nil if allowed.empty?
|
|
9
|
-
|
|
12
|
+
|
|
13
|
+
if request_origin && valid_origin?(request_origin) && matches_allowed?(request_origin, allowed)
|
|
14
|
+
return request_origin
|
|
15
|
+
end
|
|
10
16
|
|
|
11
17
|
# Don't return a wildcard pattern as a literal origin — use '*' instead
|
|
12
18
|
first = allowed.first
|
|
@@ -18,7 +24,7 @@ module Belt
|
|
|
18
24
|
|
|
19
25
|
allowed.any? do |pattern|
|
|
20
26
|
if pattern.include?('*')
|
|
21
|
-
regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\*', '[
|
|
27
|
+
regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\*', '[a-z0-9\\-]+')}\\z")
|
|
22
28
|
regex.match?(origin)
|
|
23
29
|
else
|
|
24
30
|
pattern == origin
|
|
@@ -26,6 +32,17 @@ module Belt
|
|
|
26
32
|
end
|
|
27
33
|
end
|
|
28
34
|
|
|
35
|
+
# Validate that the origin looks like a legitimate URL scheme+host.
|
|
36
|
+
# Rejects origins with path components, whitespace, or unexpected characters.
|
|
37
|
+
def self.valid_origin?(origin)
|
|
38
|
+
return false if origin.nil? || origin.empty?
|
|
39
|
+
return false if origin.length > MAX_ORIGIN_LENGTH
|
|
40
|
+
return false unless origin.match?(%r{\A https?://[a-z0-9\-.:]+\z}ix)
|
|
41
|
+
|
|
42
|
+
# Reject origins with user-info, paths, queries, or fragments
|
|
43
|
+
!origin.include?('@') && !origin.include?('?') && !origin.include?('#')
|
|
44
|
+
end
|
|
45
|
+
|
|
29
46
|
def self.origin_from_event(event)
|
|
30
47
|
return nil unless event.is_a?(Hash)
|
|
31
48
|
|
|
@@ -2,41 +2,79 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'error_logging'
|
|
4
4
|
require_relative 'cors_origin'
|
|
5
|
+
require_relative '../http_status'
|
|
5
6
|
|
|
6
7
|
module Belt
|
|
7
8
|
module Helpers
|
|
8
9
|
module Response
|
|
10
|
+
# Maximum request body size (10 MB). Bodies larger than this are rejected
|
|
11
|
+
# to prevent memory exhaustion attacks on Lambda functions.
|
|
12
|
+
MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024
|
|
13
|
+
|
|
9
14
|
def cors_headers(event = nil)
|
|
10
15
|
event = @event if event.nil? && instance_variable_defined?(:@event)
|
|
11
16
|
origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
|
|
17
|
+
allow_headers = 'Content-Type,Accept,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
|
|
12
18
|
headers = {
|
|
13
|
-
'Access-Control-Allow-Headers' =>
|
|
19
|
+
'Access-Control-Allow-Headers' => allow_headers,
|
|
14
20
|
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
|
|
15
21
|
'Access-Control-Max-Age' => '300',
|
|
16
|
-
'Content-Type' => 'application/json'
|
|
22
|
+
'Content-Type' => 'application/json',
|
|
23
|
+
'X-Content-Type-Options' => 'nosniff',
|
|
24
|
+
'Vary' => 'Origin'
|
|
17
25
|
}
|
|
18
26
|
headers['Access-Control-Allow-Origin'] = origin if origin
|
|
19
27
|
headers
|
|
20
28
|
end
|
|
21
29
|
|
|
30
|
+
# JSON success. Status accepts Integer or Symbol (:created, :ok, …).
|
|
22
31
|
def success_response(body, status_code = 200)
|
|
23
|
-
{ statusCode: status_code, headers: cors_headers, body: JSON.generate(body) }
|
|
32
|
+
{ statusCode: resolve_status(status_code), headers: cors_headers, body: JSON.generate(body) }
|
|
24
33
|
end
|
|
25
34
|
|
|
35
|
+
# JSON error. Status accepts Integer or Symbol (:unprocessable_entity, :not_found, …).
|
|
26
36
|
def error_response(message, status_code = 400, error_details = nil)
|
|
27
37
|
body = { error: message }
|
|
28
38
|
if error_details
|
|
29
39
|
body[:details] = error_details.is_a?(Hash) ? error_details : { message: error_details.to_s }
|
|
30
40
|
end
|
|
31
|
-
{ statusCode: status_code, headers: cors_headers, body: JSON.generate(body) }
|
|
41
|
+
{ statusCode: resolve_status(status_code), headers: cors_headers, body: JSON.generate(body) }
|
|
32
42
|
end
|
|
33
43
|
|
|
34
44
|
def html_response(html, status_code = 200)
|
|
35
45
|
event = @event if instance_variable_defined?(:@event)
|
|
36
46
|
origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
|
|
37
|
-
headers = {
|
|
47
|
+
headers = {
|
|
48
|
+
'Content-Type' => 'text/html; charset=utf-8',
|
|
49
|
+
'X-Content-Type-Options' => 'nosniff',
|
|
50
|
+
'X-Frame-Options' => 'DENY',
|
|
51
|
+
'Referrer-Policy' => 'strict-origin-when-cross-origin'
|
|
52
|
+
}
|
|
38
53
|
headers['Access-Control-Allow-Origin'] = origin if origin
|
|
39
|
-
{ statusCode: status_code, headers: headers, body: html }
|
|
54
|
+
{ statusCode: resolve_status(status_code), headers: headers, body: html }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Empty-body response (Rails-style). Useful for destroy / create-without-body.
|
|
58
|
+
#
|
|
59
|
+
# head :created # 201, empty body
|
|
60
|
+
# head :no_content # 204
|
|
61
|
+
# head 201
|
|
62
|
+
#
|
|
63
|
+
def head(status = :no_content)
|
|
64
|
+
{ statusCode: resolve_status(status), headers: cors_headers, body: '' }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Set the status used by implicit assigns / HTML render (when you don't
|
|
68
|
+
# call success_response / render yourself).
|
|
69
|
+
#
|
|
70
|
+
# def create
|
|
71
|
+
# @post = Post.create!(...)
|
|
72
|
+
# response_status :created
|
|
73
|
+
# end
|
|
74
|
+
# # → 201 + { post: {...} }
|
|
75
|
+
#
|
|
76
|
+
def response_status(status)
|
|
77
|
+
@__response_status = resolve_status(status)
|
|
40
78
|
end
|
|
41
79
|
|
|
42
80
|
def handle_error_and_respond(error, message, context = {}, status_code = 500)
|
|
@@ -56,6 +94,10 @@ module Belt
|
|
|
56
94
|
|
|
57
95
|
private
|
|
58
96
|
|
|
97
|
+
def resolve_status(status)
|
|
98
|
+
Belt::HttpStatus.resolve(status)
|
|
99
|
+
end
|
|
100
|
+
|
|
59
101
|
def verbose_errors_enabled?
|
|
60
102
|
env = ENV['ENVIRONMENT']&.downcase || ''
|
|
61
103
|
env.start_with?('dev') || env == 'local' || env == 'test'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
# Rails/Rack-style symbolic HTTP status codes.
|
|
5
|
+
# Mirrors the common subset of Rack::Utils::SYMBOL_TO_STATUS_CODE so apps
|
|
6
|
+
# can write `head :created` / `success_response(body, :created)` without
|
|
7
|
+
# taking a rack dependency.
|
|
8
|
+
module HttpStatus
|
|
9
|
+
SYMBOL_TO_STATUS_CODE = {
|
|
10
|
+
continue: 100,
|
|
11
|
+
switching_protocols: 101,
|
|
12
|
+
processing: 102,
|
|
13
|
+
ok: 200,
|
|
14
|
+
created: 201,
|
|
15
|
+
accepted: 202,
|
|
16
|
+
non_authoritative_information: 203,
|
|
17
|
+
no_content: 204,
|
|
18
|
+
reset_content: 205,
|
|
19
|
+
partial_content: 206,
|
|
20
|
+
multi_status: 207,
|
|
21
|
+
already_reported: 208,
|
|
22
|
+
im_used: 226,
|
|
23
|
+
multiple_choices: 300,
|
|
24
|
+
moved_permanently: 301,
|
|
25
|
+
found: 302,
|
|
26
|
+
see_other: 303,
|
|
27
|
+
not_modified: 304,
|
|
28
|
+
use_proxy: 305,
|
|
29
|
+
temporary_redirect: 307,
|
|
30
|
+
permanent_redirect: 308,
|
|
31
|
+
bad_request: 400,
|
|
32
|
+
unauthorized: 401,
|
|
33
|
+
payment_required: 402,
|
|
34
|
+
forbidden: 403,
|
|
35
|
+
not_found: 404,
|
|
36
|
+
method_not_allowed: 405,
|
|
37
|
+
not_acceptable: 406,
|
|
38
|
+
proxy_authentication_required: 407,
|
|
39
|
+
request_timeout: 408,
|
|
40
|
+
conflict: 409,
|
|
41
|
+
gone: 410,
|
|
42
|
+
length_required: 411,
|
|
43
|
+
precondition_failed: 412,
|
|
44
|
+
payload_too_large: 413,
|
|
45
|
+
uri_too_long: 414,
|
|
46
|
+
unsupported_media_type: 415,
|
|
47
|
+
range_not_satisfiable: 416,
|
|
48
|
+
expectation_failed: 417,
|
|
49
|
+
unprocessable_entity: 422,
|
|
50
|
+
locked: 423,
|
|
51
|
+
failed_dependency: 424,
|
|
52
|
+
upgrade_required: 426,
|
|
53
|
+
precondition_required: 428,
|
|
54
|
+
too_many_requests: 429,
|
|
55
|
+
request_header_fields_too_large: 431,
|
|
56
|
+
unavailable_for_legal_reasons: 451,
|
|
57
|
+
internal_server_error: 500,
|
|
58
|
+
not_implemented: 501,
|
|
59
|
+
bad_gateway: 502,
|
|
60
|
+
service_unavailable: 503,
|
|
61
|
+
gateway_timeout: 504,
|
|
62
|
+
http_version_not_supported: 505,
|
|
63
|
+
variant_also_negotiates: 506,
|
|
64
|
+
insufficient_storage: 507,
|
|
65
|
+
loop_detected: 508,
|
|
66
|
+
not_extended: 510,
|
|
67
|
+
network_authentication_required: 511
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
# Resolve a status to an integer code.
|
|
73
|
+
# Accepts Integer (200), String ("201"), or Symbol (:created).
|
|
74
|
+
def resolve(status)
|
|
75
|
+
case status
|
|
76
|
+
when Integer
|
|
77
|
+
status
|
|
78
|
+
when String
|
|
79
|
+
Integer(status)
|
|
80
|
+
when Symbol
|
|
81
|
+
SYMBOL_TO_STATUS_CODE.fetch(status) do
|
|
82
|
+
raise ArgumentError, "Unknown HTTP status symbol: #{status.inspect}"
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
raise ArgumentError, "status must be Integer, String, or Symbol (got #{status.class})"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/belt/lambda_handler.rb
CHANGED
|
@@ -76,11 +76,8 @@ module Belt
|
|
|
76
76
|
|
|
77
77
|
return { statusCode: 200, headers: cors_headers(event), body: '{}' } if event['httpMethod'] == 'OPTIONS'
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
rescue JSON::ParserError
|
|
82
|
-
return error_response('Invalid JSON in request body')
|
|
83
|
-
end
|
|
79
|
+
body = parse_request_body(event)
|
|
80
|
+
return body if body.is_a?(Hash) && body[:statusCode]
|
|
84
81
|
|
|
85
82
|
begin
|
|
86
83
|
result = execute(path: event['path'], body: body, event: event)
|
|
@@ -107,6 +104,18 @@ module Belt
|
|
|
107
104
|
|
|
108
105
|
private
|
|
109
106
|
|
|
107
|
+
def parse_request_body(event)
|
|
108
|
+
raw_body = event['body'] || '{}'
|
|
109
|
+
|
|
110
|
+
if raw_body.bytesize > Belt::Helpers::Response::MAX_REQUEST_BODY_SIZE
|
|
111
|
+
return error_response('Request body too large', 413)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
JSON.parse(raw_body)
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
error_response('Invalid JSON in request body', 400)
|
|
117
|
+
end
|
|
118
|
+
|
|
110
119
|
def init_observability(context:)
|
|
111
120
|
service_name = ENV['ACTION'] || context.function_name.split('-').last
|
|
112
121
|
|
data/lib/belt/rendering.rb
CHANGED
data/lib/belt/version.rb
CHANGED
|
@@ -12,39 +12,39 @@
|
|
|
12
12
|
<div class="overlay">
|
|
13
13
|
<h1><%= h(@title) %></h1>
|
|
14
14
|
<p class="subtitle"><%= h(@subtitle) %></p>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<
|
|
15
|
+
|
|
16
|
+
<div class="stack-check">
|
|
17
|
+
<div class="check-item check-pass">
|
|
18
|
+
<span class="icon">✓</span>
|
|
19
|
+
<span>API Gateway</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="arrow">→</div>
|
|
22
|
+
<div class="check-item check-pass">
|
|
23
|
+
<span class="icon">✓</span>
|
|
24
|
+
<span>Lambda</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="arrow">→</div>
|
|
27
|
+
<%- if @dynamodb_connected -%>
|
|
28
|
+
<div class="check-item check-pass">
|
|
29
|
+
<span class="icon">✓</span>
|
|
30
|
+
<span>DynamoDB</span>
|
|
31
|
+
</div>
|
|
32
|
+
<%- else -%>
|
|
33
|
+
<div class="check-item check-neutral">
|
|
34
|
+
<span class="icon">○</span>
|
|
35
|
+
<span>DynamoDB</span>
|
|
36
|
+
</div>
|
|
37
|
+
<%- end -%>
|
|
27
38
|
</div>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<
|
|
32
|
-
|
|
39
|
+
|
|
40
|
+
<div class="next-steps">
|
|
41
|
+
<h2>Next Steps</h2>
|
|
42
|
+
<ol>
|
|
43
|
+
<li>Scaffold a resource: <code><%= h(@scaffold_hint_cmd) %></code></li>
|
|
44
|
+
<li>Deploy: <code>belt deploy</code></li>
|
|
45
|
+
<li>This page will be replaced once you define your own root route.</li>
|
|
46
|
+
</ol>
|
|
33
47
|
</div>
|
|
34
|
-
<%- else -%>
|
|
35
|
-
<div class="check-item check-warn">
|
|
36
|
-
<span class="icon">⚠</span>
|
|
37
|
-
<span>DynamoDB</span>
|
|
38
|
-
</div>
|
|
39
|
-
<%- end -%>
|
|
40
|
-
</div>
|
|
41
|
-
<div class="next-steps">
|
|
42
|
-
<h2>Next Steps</h2>
|
|
43
|
-
<ol>
|
|
44
|
-
<li>Generate a resource: <code>belt g resource post title:string body:text</code></li>
|
|
45
|
-
<li>Deploy: <code>belt deploy</code></li>
|
|
46
|
-
<li>This page will be replaced once you define your own root route.</li>
|
|
47
|
-
</ol>
|
|
48
48
|
</div>
|
|
49
49
|
</div>
|
|
50
50
|
</body>
|
data/lib/belt.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'belt/version'
|
|
4
4
|
require_relative 'belt/root'
|
|
5
|
+
require_relative 'belt/configuration'
|
|
6
|
+
require_relative 'belt/http_status'
|
|
5
7
|
require_relative 'belt/parameters'
|
|
6
8
|
require_relative 'belt/observability'
|
|
7
9
|
require_relative 'belt/lambda_handler'
|
|
@@ -18,6 +20,20 @@ module Belt
|
|
|
18
20
|
class << self
|
|
19
21
|
attr_reader :controller_paths
|
|
20
22
|
|
|
23
|
+
# Runtime configuration (lambda/config/environment.rb). Separate from the
|
|
24
|
+
# CLI sandboxed DSL in infrastructure/<env>/belt.rb.
|
|
25
|
+
def configuration
|
|
26
|
+
@configuration ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configure
|
|
30
|
+
yield configuration
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset_configuration!
|
|
34
|
+
@configuration = Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
21
37
|
# Auto-discover lambda/controllers dirs in all loaded gems
|
|
22
38
|
def gem_controller_paths
|
|
23
39
|
@gem_controller_paths ||= discover_gem_paths('lambda/controllers')
|