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.
- 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/backup_runner.rb +37 -54
- 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
|
@@ -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>
|
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')
|
data/lib/belt_controller/base.rb
CHANGED
|
@@ -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,10 +1,10 @@
|
|
|
1
1
|
:root {
|
|
2
|
-
font-family:
|
|
2
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
3
3
|
line-height: 1.5;
|
|
4
|
-
color: #
|
|
5
|
-
background-color: #
|
|
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: {
|
|
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
|
-
|
|
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
|
|