belt 0.2.8 → 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.
@@ -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' => 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
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
@@ -54,6 +54,7 @@ module Belt
54
54
  # render layout: false — skip layout (default: no layout)
55
55
  #
56
56
  def render(options = {})
57
+ # Status may be Integer or Symbol (:created, :ok, …)
57
58
  status = options.fetch(:status, 200)
58
59
 
59
60
  html = if options.key?(:inline)
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.2.8'
4
+ VERSION = '0.2.10'
5
5
  end
@@ -12,39 +12,39 @@
12
12
  <div class="overlay">
13
13
  <h1><%= h(@title) %></h1>
14
14
  <p class="subtitle"><%= h(@subtitle) %></p>
15
- </div>
16
- </div>
17
- <div class="container">
18
- <div class="stack-check">
19
- <div class="check-item check-pass">
20
- <span class="icon">✓</span>
21
- <span>API Gateway</span>
22
- </div>
23
- <div class="arrow">→</div>
24
- <div class="check-item check-pass">
25
- <span class="icon">✓</span>
26
- <span>Lambda</span>
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
- <div class="arrow">→</div>
29
- <%- if @dynamodb_connected -%>
30
- <div class="check-item check-pass">
31
- <span class="icon">✓</span>
32
- <span>DynamoDB</span>
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')
@@ -7,11 +7,13 @@ require_relative '../belt/helpers/response'
7
7
  require_relative '../belt/helpers/error_logging'
8
8
  require_relative '../belt/helpers/cors_origin'
9
9
  require_relative '../belt/rendering'
10
+ require_relative 'implicit_response'
10
11
 
11
12
  module BeltController
12
13
  class Base
13
14
  include Belt::Helpers::Response
14
15
  include Belt::Rendering
16
+ include ImplicitResponse
15
17
 
16
18
  attr_reader :event, :body
17
19
 
@@ -48,6 +50,25 @@ module BeltController
48
50
  skipped_before_actions << { method: method_name, only: only&.map(&:to_sym), except: except&.map(&:to_sym) }
49
51
  end
50
52
 
53
+ # Implicit response format when the action does not call success_response /
54
+ # error_response / html_response / render / head.
55
+ # Inherits up the controller chain; falls back to Belt.configuration.default_format.
56
+ def default_format
57
+ return @default_format if defined?(@default_format)
58
+ return superclass.default_format if superclass.respond_to?(:default_format)
59
+
60
+ Belt.configuration.default_format
61
+ end
62
+
63
+ def default_format=(value)
64
+ format = value.to_sym
65
+ unless Belt::Configuration::VALID_FORMATS.include?(format)
66
+ raise ArgumentError, "default_format must be :json or :html (got #{value.inspect})"
67
+ end
68
+
69
+ @default_format = format
70
+ end
71
+
51
72
  def all_before_actions
52
73
  if superclass.respond_to?(:all_before_actions)
53
74
  superclass.all_before_actions + before_actions
@@ -115,9 +136,11 @@ module BeltController
115
136
  end
116
137
 
117
138
  run_before_actions(action_sym)
139
+ # Snapshot ivars so we only auto-serialize assigns set by the action (Rails-style)
140
+ @__assigns_before = instance_variables
118
141
  result = send(action_sym)
119
142
  run_after_actions(action_sym)
120
- result
143
+ finalize_response(result)
121
144
  rescue StandardError => e
122
145
  handle_exception(e)
123
146
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BeltController
4
+ # Rails-like implicit JSON/HTML responses from action assigns / return values.
5
+ # Included by BeltController::Base so the base class stays under Metrics/ClassLength.
6
+ module ImplicitResponse
7
+ # Never auto-serialize these into the JSON body (framework internals).
8
+ FRAMEWORK_IVARS = %i[
9
+ @event @raw_body @params @current_action @current_user_id @user_groups
10
+ @logger @__assigns_before @__response_status
11
+ ].freeze
12
+
13
+ private
14
+
15
+ # Turn the action return value (or instance-variable assigns) into an API Gateway response.
16
+ #
17
+ # Rails-like behavior for API-first controllers (default_format :json):
18
+ # def index
19
+ # @posts = Post.all # → success_response({ posts: [...] })
20
+ # end
21
+ #
22
+ # HTML controllers (default_format :html):
23
+ # def show
24
+ # @post = Post.find(...) # → render template views/<ctrl>/show.html.erb
25
+ # end
26
+ #
27
+ # Explicit responses still win:
28
+ # success_response(...), error_response(...), html_response(...), render, head
29
+ #
30
+ # Non-200 with implicit body:
31
+ # def create
32
+ # @post = Post.create!(...)
33
+ # response_status :created
34
+ # end
35
+ def finalize_response(result)
36
+ return result if lambda_response?(result)
37
+
38
+ status = @__response_status || 200
39
+
40
+ return render(status: status) if self.class.default_format == :html
41
+
42
+ # Prefer assigns when present. Ruby assignment returns the RHS, so
43
+ # `@posts = Post.all` would otherwise look like an explicit return value.
44
+ # (Rails ignores the action return value entirely; we keep a thin fallback
45
+ # for explicit non-assign returns.)
46
+ assigns = collect_assigns
47
+ return success_response(assigns, status) if assigns.any?
48
+
49
+ return success_response({}, status) if result.nil?
50
+
51
+ success_response(serialize_for_response(result), status)
52
+ end
53
+
54
+ def lambda_response?(result)
55
+ result.is_a?(Hash) && (result.key?(:statusCode) || result.key?('statusCode'))
56
+ end
57
+
58
+ # Instance variables set during the action (excluding framework internals).
59
+ # Keys are the ivar names without @ — so @posts becomes { posts: ... }.
60
+ def collect_assigns
61
+ before = @__assigns_before || []
62
+ (instance_variables - before - FRAMEWORK_IVARS).each_with_object({}) do |ivar, hash|
63
+ key = ivar.to_s.delete_prefix('@')
64
+ next if key.start_with?('_')
65
+
66
+ hash[key.to_sym] = serialize_for_response(instance_variable_get(ivar))
67
+ end
68
+ end
69
+
70
+ # Deep-serialize models/relations into JSON-friendly hashes/arrays.
71
+ # ActiveItem::Relation (and any Enumerable of models) maps via to_h on each record.
72
+ def serialize_for_response(value)
73
+ case value
74
+ when nil, String, Numeric, TrueClass, FalseClass
75
+ value
76
+ when Symbol
77
+ value.to_s
78
+ when Hash
79
+ value.each_with_object({}) do |(k, v), h|
80
+ h[k.is_a?(Symbol) ? k : k.to_s] = serialize_for_response(v)
81
+ end
82
+ when Array
83
+ value.map { |v| serialize_for_response(v) }
84
+ else
85
+ serialize_object(value)
86
+ end
87
+ end
88
+
89
+ def serialize_object(value)
90
+ # Relations are Enumerable — map each record (don't call Enumerable#to_h)
91
+ if defined?(ActiveItem::Relation) && value.is_a?(ActiveItem::Relation)
92
+ value.map { |record| serialize_for_response(record) }
93
+ elsif value.respond_to?(:to_h) && !value.is_a?(Enumerable)
94
+ serialize_for_response(value.to_h)
95
+ elsif value.is_a?(Enumerable)
96
+ value.map { |item| serialize_for_response(item) }
97
+ elsif value.respond_to?(:to_h)
98
+ serialize_for_response(value.to_h)
99
+ else
100
+ value
101
+ end
102
+ end
103
+ end
104
+ end
@@ -1,5 +1,6 @@
1
1
  terraform {
2
2
  backend "s3" {
3
+ # Resolved to belt-terraform-state-<account-id> by `belt setup state` / `belt new`
3
4
  bucket = "belt-terraform-state"
4
5
  key = "<%= s3_safe_name(@app_name) %>/<%= @env_name %>/terraform.tfstate"
5
6
  region = "us-east-1"
@@ -1,10 +1,10 @@
1
1
  :root {
2
- font-family: system-ui, -apple-system, sans-serif;
2
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
3
3
  line-height: 1.5;
4
- color: #213547;
5
- background-color: #ffffff;
4
+ color: #2d3748;
5
+ background-color: #f8f9fa;
6
6
  }
7
7
 
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
 
10
- body { min-height: 100vh; }
10
+ body { min-height: 100vh; background: #f8f9fa; }
@@ -3,15 +3,32 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
3
3
  export async function apiClient(path, options = {}) {
4
4
  const { method = 'GET', body, headers = {} } = options
5
5
 
6
+ // Only set Content-Type when sending a body. application/json on a GET forces a
7
+ // CORS preflight; Accept alone is safelisted and stays a simple request.
6
8
  const config = {
7
9
  method,
8
- headers: { 'Content-Type': 'application/json', ...headers }
10
+ headers: {
11
+ Accept: 'application/json',
12
+ ...(body !== undefined && body !== null ? { 'Content-Type': 'application/json' } : {}),
13
+ ...headers
14
+ }
9
15
  }
10
16
 
11
- if (body) config.body = JSON.stringify(body)
17
+ if (body !== undefined && body !== null) config.body = JSON.stringify(body)
12
18
 
13
19
  const response = await fetch(`${API_URL}${path}`, config)
14
- const data = await response.json()
20
+
21
+ let data
22
+ const text = await response.text()
23
+ try {
24
+ data = text ? JSON.parse(text) : {}
25
+ } catch {
26
+ throw new Error(
27
+ response.ok
28
+ ? 'API returned non-JSON (is VITE_API_URL pointing at the API Gateway?)'
29
+ : `Request failed: ${response.status}`
30
+ )
31
+ }
15
32
 
16
33
  if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`)
17
34