belt 0.2.9 → 0.2.10
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 +87 -0
- data/README.md +48 -8
- data/lib/belt/action_router.rb +1 -1
- 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/doctor_command.rb +208 -0
- data/lib/belt/cli/environment_command.rb +6 -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/setup_command.rb +35 -10
- data/lib/belt/cli.rb +4 -0
- data/lib/belt/configuration.rb +30 -0
- data/lib/belt/controllers/welcome_controller.rb +69 -2
- data/lib/belt/helpers/response.rb +35 -4
- data/lib/belt/http_status.rb +89 -0
- 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 -0
- 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
- metadata +20 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'rubygems/package'
|
|
6
|
+
|
|
7
|
+
module Belt
|
|
8
|
+
module CLI
|
|
9
|
+
# Turns Gemfile `path:` gems into real `.gem` files in vendor/cache and
|
|
10
|
+
# rewrites the Gemfile/lock so Docker `bundle install` produces a normal
|
|
11
|
+
# gem install (with specifications/). Required for Lambda bare `require`
|
|
12
|
+
# without bundler/setup.
|
|
13
|
+
#
|
|
14
|
+
# Only mutates files under +build_dir+ — never the app's real Gemfile.
|
|
15
|
+
class PathGemMaterializer
|
|
16
|
+
PathSource = Struct.new(:remote, :gems, keyword_init: true)
|
|
17
|
+
PathGem = Struct.new(:name, :version, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def self.materialize!(build_dir, project_root:)
|
|
20
|
+
new(build_dir, project_root: project_root).materialize!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(build_dir, project_root:)
|
|
24
|
+
@build_dir = build_dir
|
|
25
|
+
@project_root = project_root
|
|
26
|
+
@gemfile = File.join(build_dir, 'Gemfile')
|
|
27
|
+
@lockfile = File.join(build_dir, 'Gemfile.lock')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array<String>] names of gems materialized (empty if none)
|
|
31
|
+
def materialize!
|
|
32
|
+
return [] unless File.exist?(@gemfile) && File.exist?(@lockfile)
|
|
33
|
+
|
|
34
|
+
sources = parse_path_sources(File.read(@lockfile))
|
|
35
|
+
return [] if sources.empty?
|
|
36
|
+
|
|
37
|
+
cache_dir = File.join(@build_dir, 'vendor', 'cache')
|
|
38
|
+
FileUtils.mkdir_p(cache_dir)
|
|
39
|
+
|
|
40
|
+
materialized = []
|
|
41
|
+
sources.each do |source|
|
|
42
|
+
source_path = resolve_remote(source.remote)
|
|
43
|
+
abort "✗ path gem source missing: #{source.remote} (resolved #{source_path})" unless Dir.exist?(source_path)
|
|
44
|
+
|
|
45
|
+
source.gems.each do |gem|
|
|
46
|
+
gem_file = build_gem(source_path, gem)
|
|
47
|
+
dest = File.join(cache_dir, File.basename(gem_file))
|
|
48
|
+
FileUtils.cp(gem_file, dest)
|
|
49
|
+
FileUtils.rm_f(gem_file)
|
|
50
|
+
rewrite_gemfile_path_to_version!(gem.name, gem.version)
|
|
51
|
+
materialized << gem.name
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return [] if materialized.empty?
|
|
56
|
+
|
|
57
|
+
relock!(materialized)
|
|
58
|
+
materialized
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse_path_sources(lockfile_content)
|
|
64
|
+
sources = []
|
|
65
|
+
lockfile_content.scan(/^PATH\n remote: (.+)\n specs:\n((?: .+\n)*)/) do |remote, specs_block|
|
|
66
|
+
gems = []
|
|
67
|
+
specs_block.each_line do |line|
|
|
68
|
+
next unless line.match?(/^ \S/)
|
|
69
|
+
next if line.start_with?(' ') # dependency lines are deeper
|
|
70
|
+
|
|
71
|
+
match = line.match(/^ (\S+)\s+\(([^)]+)\)/)
|
|
72
|
+
gems << PathGem.new(name: match[1], version: match[2]) if match
|
|
73
|
+
end
|
|
74
|
+
sources << PathSource.new(remote: remote.strip, gems: gems) if gems.any?
|
|
75
|
+
end
|
|
76
|
+
sources
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve_remote(remote)
|
|
80
|
+
return remote if remote.start_with?('/')
|
|
81
|
+
|
|
82
|
+
File.expand_path(remote, @project_root)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_gem(source_path, gem)
|
|
86
|
+
gemspec = find_gemspec(source_path, gem.name)
|
|
87
|
+
abort "✗ No gemspec for path gem #{gem.name} under #{source_path}" unless gemspec
|
|
88
|
+
|
|
89
|
+
Dir.chdir(source_path) do
|
|
90
|
+
spec = Gem::Specification.load(File.basename(gemspec))
|
|
91
|
+
abort "✗ Failed to load #{gemspec}" unless spec
|
|
92
|
+
|
|
93
|
+
if spec.version.to_s != gem.version
|
|
94
|
+
abort "✗ path gem #{gem.name} version mismatch: gemspec #{spec.version}, lock #{gem.version}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Gem::Package.build writes the .gem into cwd
|
|
98
|
+
built = Gem::Package.build(spec)
|
|
99
|
+
File.expand_path(built)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_gemspec(source_path, gem_name)
|
|
104
|
+
preferred = File.join(source_path, "#{gem_name}.gemspec")
|
|
105
|
+
return preferred if File.exist?(preferred)
|
|
106
|
+
|
|
107
|
+
Dir.glob(File.join(source_path, '*.gemspec')).first
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def rewrite_gemfile_path_to_version!(name, version)
|
|
111
|
+
content = File.read(@gemfile)
|
|
112
|
+
new_content = content.lines.map { |line| convert_path_line(line, name, version) }.join
|
|
113
|
+
abort "✗ Could not rewrite path: for gem #{name.inspect} in build Gemfile" if new_content == content
|
|
114
|
+
File.write(@gemfile, new_content)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Converts `gem 'name', path: '...'` (and variants with other kwargs) into
|
|
118
|
+
# a version-pinned line. Leaves non-matching lines alone.
|
|
119
|
+
def convert_path_line(line, name, version)
|
|
120
|
+
return line unless line.match?(/gem\s+(['"])#{Regexp.escape(name)}\1/)
|
|
121
|
+
return line unless line.match?(/\bpath:\s*/)
|
|
122
|
+
|
|
123
|
+
quote = line[/gem\s+(['"])/, 1] || "'"
|
|
124
|
+
cleaned = line.sub(/,?\s*path:\s*['"][^'"]+['"]/, '')
|
|
125
|
+
cleaned.sub(/gem\s+(['"])#{Regexp.escape(name)}\1/) do
|
|
126
|
+
"gem #{quote}#{name}#{quote}, #{quote}#{version}#{quote}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def relock!(gem_names)
|
|
131
|
+
Dir.chdir(@build_dir) do
|
|
132
|
+
# vendor/cache has the built .gem — Bundler resolves the unpublished version
|
|
133
|
+
output, status = Open3.capture2e('bundle', 'lock', '--update', *gem_names)
|
|
134
|
+
return if status.success?
|
|
135
|
+
|
|
136
|
+
abort "✗ Failed to re-lock after materializing path gems (#{gem_names.join(', ')}):\n#{output}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -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,7 @@ 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'
|
|
24
25
|
|
|
25
26
|
module Belt
|
|
26
27
|
module CLI
|
|
@@ -33,6 +34,7 @@ module Belt
|
|
|
33
34
|
%w[console c] => Belt::CLI::ConsoleCommand,
|
|
34
35
|
%w[tasks --tasks -T] => Belt::CLI::TasksCommand,
|
|
35
36
|
'setup' => Belt::CLI::SetupCommand,
|
|
37
|
+
'doctor' => Belt::CLI::DoctorCommand,
|
|
36
38
|
'deploy' => Belt::CLI::DeployCommand,
|
|
37
39
|
'frontend' => Belt::CLI::FrontendEnvCommand,
|
|
38
40
|
%w[server s] => Belt::CLI::ServerCommand,
|
|
@@ -114,6 +116,7 @@ module Belt
|
|
|
114
116
|
setup state Create/select S3 state bucket
|
|
115
117
|
setup tables <env> Generate DynamoDB tables from schema
|
|
116
118
|
setup frontend <env> Generate S3 + CloudFront infrastructure
|
|
119
|
+
doctor Check system dependencies and AWS config
|
|
117
120
|
init [environment] <env> terraform init for environment
|
|
118
121
|
plan [environment] <env> terraform plan for environment
|
|
119
122
|
apply [environment] <env> terraform apply for environment
|
|
@@ -133,6 +136,7 @@ module Belt
|
|
|
133
136
|
|
|
134
137
|
Examples:
|
|
135
138
|
belt new blog --frontend react
|
|
139
|
+
belt new blog --frontend react -v # list every created file
|
|
136
140
|
belt generate scaffold post title:string content:text status:string
|
|
137
141
|
belt destroy scaffold post
|
|
138
142
|
belt generate frontend react
|
|
@@ -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
|
|
|
@@ -2,6 +2,7 @@
|
|
|
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
|
|
@@ -9,8 +10,9 @@ module Belt
|
|
|
9
10
|
def cors_headers(event = nil)
|
|
10
11
|
event = @event if event.nil? && instance_variable_defined?(:@event)
|
|
11
12
|
origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
|
|
13
|
+
allow_headers = 'Content-Type,Accept,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
|
|
12
14
|
headers = {
|
|
13
|
-
'Access-Control-Allow-Headers' =>
|
|
15
|
+
'Access-Control-Allow-Headers' => allow_headers,
|
|
14
16
|
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
|
|
15
17
|
'Access-Control-Max-Age' => '300',
|
|
16
18
|
'Content-Type' => 'application/json'
|
|
@@ -19,16 +21,18 @@ module Belt
|
|
|
19
21
|
headers
|
|
20
22
|
end
|
|
21
23
|
|
|
24
|
+
# JSON success. Status accepts Integer or Symbol (:created, :ok, …).
|
|
22
25
|
def success_response(body, status_code = 200)
|
|
23
|
-
{ statusCode: status_code, headers: cors_headers, body: JSON.generate(body) }
|
|
26
|
+
{ statusCode: resolve_status(status_code), headers: cors_headers, body: JSON.generate(body) }
|
|
24
27
|
end
|
|
25
28
|
|
|
29
|
+
# JSON error. Status accepts Integer or Symbol (:unprocessable_entity, :not_found, …).
|
|
26
30
|
def error_response(message, status_code = 400, error_details = nil)
|
|
27
31
|
body = { error: message }
|
|
28
32
|
if error_details
|
|
29
33
|
body[:details] = error_details.is_a?(Hash) ? error_details : { message: error_details.to_s }
|
|
30
34
|
end
|
|
31
|
-
{ statusCode: status_code, headers: cors_headers, body: JSON.generate(body) }
|
|
35
|
+
{ statusCode: resolve_status(status_code), headers: cors_headers, body: JSON.generate(body) }
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def html_response(html, status_code = 200)
|
|
@@ -36,7 +40,30 @@ module Belt
|
|
|
36
40
|
origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
|
|
37
41
|
headers = { 'Content-Type' => 'text/html; charset=utf-8' }
|
|
38
42
|
headers['Access-Control-Allow-Origin'] = origin if origin
|
|
39
|
-
{ statusCode: status_code, headers: headers, body: html }
|
|
43
|
+
{ statusCode: resolve_status(status_code), headers: headers, body: html }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Empty-body response (Rails-style). Useful for destroy / create-without-body.
|
|
47
|
+
#
|
|
48
|
+
# head :created # 201, empty body
|
|
49
|
+
# head :no_content # 204
|
|
50
|
+
# head 201
|
|
51
|
+
#
|
|
52
|
+
def head(status = :no_content)
|
|
53
|
+
{ statusCode: resolve_status(status), headers: cors_headers, body: '' }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Set the status used by implicit assigns / HTML render (when you don't
|
|
57
|
+
# call success_response / render yourself).
|
|
58
|
+
#
|
|
59
|
+
# def create
|
|
60
|
+
# @post = Post.create!(...)
|
|
61
|
+
# response_status :created
|
|
62
|
+
# end
|
|
63
|
+
# # → 201 + { post: {...} }
|
|
64
|
+
#
|
|
65
|
+
def response_status(status)
|
|
66
|
+
@__response_status = resolve_status(status)
|
|
40
67
|
end
|
|
41
68
|
|
|
42
69
|
def handle_error_and_respond(error, message, context = {}, status_code = 500)
|
|
@@ -56,6 +83,10 @@ module Belt
|
|
|
56
83
|
|
|
57
84
|
private
|
|
58
85
|
|
|
86
|
+
def resolve_status(status)
|
|
87
|
+
Belt::HttpStatus.resolve(status)
|
|
88
|
+
end
|
|
89
|
+
|
|
59
90
|
def verbose_errors_enabled?
|
|
60
91
|
env = ENV['ENVIRONMENT']&.downcase || ''
|
|
61
92
|
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/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>
|