gemstack 0.2.5 → 0.3.0
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 +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +251 -39
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Dev
|
|
5
|
+
# Runs `gemstack dev`: the gateway on the public port, the Ruby API and
|
|
6
|
+
# Next.js on private ports chosen automatically, prefixed output, restarts
|
|
7
|
+
# of the API when configuration changes, and a clean shutdown on Ctrl-C.
|
|
8
|
+
class Supervisor
|
|
9
|
+
LOOPBACK = "127.0.0.1"
|
|
10
|
+
TICK = 0.25
|
|
11
|
+
|
|
12
|
+
attr_reader :gateway, :processes
|
|
13
|
+
|
|
14
|
+
def initialize(root:, config: GemStack.config, terminal: Terminal.new, env: ENV)
|
|
15
|
+
@root = Pathname.new(root)
|
|
16
|
+
@config = config
|
|
17
|
+
@dev = config.dev
|
|
18
|
+
@terminal = terminal
|
|
19
|
+
@env = env
|
|
20
|
+
@processes = {}
|
|
21
|
+
@stopping = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
if frontend?
|
|
26
|
+
check_node!
|
|
27
|
+
prepare_frontend
|
|
28
|
+
end
|
|
29
|
+
start
|
|
30
|
+
install_signal_handlers
|
|
31
|
+
loop_until_stopped
|
|
32
|
+
ensure
|
|
33
|
+
shutdown
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def start
|
|
37
|
+
@started_at = monotonic
|
|
38
|
+
api_port = Ports.free(LOOPBACK)
|
|
39
|
+
web_port = Ports.free(LOOPBACK) if frontend?
|
|
40
|
+
@gateway = Gateway.new(
|
|
41
|
+
port: @dev.port, bind: @dev.bind, api_path: @config.http.api_path,
|
|
42
|
+
api: Gateway::Upstream.new(:api, LOOPBACK, api_port, "Ruby API"),
|
|
43
|
+
frontend: web_port && Gateway::Upstream.new(:next, LOOPBACK, web_port, "Next.js"),
|
|
44
|
+
on_error: ->(e) { @terminal.line("gateway", "#{e.class}: #{e.message}") }
|
|
45
|
+
).start
|
|
46
|
+
|
|
47
|
+
start_processes(api_port, web_port)
|
|
48
|
+
@restart_watcher = FileWatcher.new(@dev.restart_on, root: @root, exclude: @dev.restart_exclude)
|
|
49
|
+
@recovery_watcher = FileWatcher.new(["app/**/*", "config/**/*", "Gemfile.lock"], root: @root)
|
|
50
|
+
@jobs_watcher = FileWatcher.new(["app/**/*.rb", "db/migrations/*.rb"], root: @root) if @dev.jobs_command
|
|
51
|
+
start_jobs if jobs_enabled?
|
|
52
|
+
if web_port && @dev.contract_command
|
|
53
|
+
@contract_watcher = FileWatcher.new(@dev.contract_watch, root: @root)
|
|
54
|
+
@contract_pending = true # generate once at startup
|
|
55
|
+
end
|
|
56
|
+
banner
|
|
57
|
+
watch_readiness
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def start_processes(api_port, web_port)
|
|
62
|
+
@processes[:api] = spawn("api", @dev.api_command, @root, api_env(api_port))
|
|
63
|
+
@processes[:next] = spawn("next", frontend_command(web_port), frontend_dir, frontend_env(api_port)) if web_port
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def spawn(name, command, dir, env)
|
|
67
|
+
ManagedProcess.new(name, command, terminal: @terminal, chdir: dir, env: env).start
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def stop!
|
|
71
|
+
@stopping = true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def shutdown
|
|
75
|
+
return if @shut_down
|
|
76
|
+
|
|
77
|
+
@shut_down = true
|
|
78
|
+
@terminal.puts
|
|
79
|
+
@terminal.line("gemstack", "shutting down…")
|
|
80
|
+
@processes.values.map { |process| Thread.new { process.stop } }.each(&:join)
|
|
81
|
+
@gateway&.stop
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def frontend? = frontend_dir.join("package.json").file?
|
|
87
|
+
def frontend_dir = @root.join(@dev.frontend_dir)
|
|
88
|
+
|
|
89
|
+
def api_env(port)
|
|
90
|
+
{
|
|
91
|
+
"GEMSTACK_ENV" => @env.fetch("GEMSTACK_ENV", "development"),
|
|
92
|
+
"GEMSTACK_API_HOST" => LOOPBACK,
|
|
93
|
+
"GEMSTACK_API_PORT" => port.to_s,
|
|
94
|
+
"GEMSTACK_LOG_COLOR" => @terminal.color ? "1" : "0",
|
|
95
|
+
"PORT" => nil # the public port belongs to the gateway
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def frontend_env(api_port)
|
|
100
|
+
{
|
|
101
|
+
"GEMSTACK_API_URL" => "http://#{LOOPBACK}:#{api_port}",
|
|
102
|
+
"NEXT_PUBLIC_GEMSTACK_API_PATH" => @config.http.api_path,
|
|
103
|
+
"FORCE_COLOR" => @terminal.color ? "1" : nil,
|
|
104
|
+
"PORT" => nil
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def frontend_command(port)
|
|
109
|
+
return Array(@dev.frontend_command) + ["--port", port.to_s] if @dev.frontend_command
|
|
110
|
+
|
|
111
|
+
[frontend_dir.join("node_modules/.bin/next").to_s, "dev", "--hostname", LOOPBACK, "--port", port.to_s]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Next.js exits at once on an old Node.js with a line that's easy to miss
|
|
115
|
+
# among the other output; say it up front, with the command that fixes it.
|
|
116
|
+
def check_node!(node = Toolchain.node)
|
|
117
|
+
return if node && Toolchain.node_ok?(node[:version])
|
|
118
|
+
|
|
119
|
+
wanted = [@root.join(".node-version"), @root.join(".nvmrc")].find(&:file?)&.read&.strip
|
|
120
|
+
wanted = Toolchain::LTS_NODE if wanted.nil? || wanted.empty?
|
|
121
|
+
found = node ? "Node.js #{node[:version]} (#{node[:path]})" : "no `node` on the PATH"
|
|
122
|
+
raise Error, "Next.js needs Node.js #{Toolchain::MIN_NODE.join(".")} or newer; found #{found}.\n " \
|
|
123
|
+
"→ #{Toolchain.node_hint(wanted, Toolchain.node_manager(node&.fetch(:path)))}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Installs frontend dependencies on first run so `gemstack new && gemstack dev`
|
|
127
|
+
# works even with --skip-install.
|
|
128
|
+
def prepare_frontend
|
|
129
|
+
return if frontend_dir.join("node_modules/.bin/next").exist?
|
|
130
|
+
|
|
131
|
+
manager = package_manager
|
|
132
|
+
@terminal.line("gemstack", "installing frontend dependencies with #{manager} (first run)…")
|
|
133
|
+
installer = ManagedProcess.new(manager, [manager, "install"], terminal: @terminal, chdir: frontend_dir).start
|
|
134
|
+
sleep 0.1 while installer.running?
|
|
135
|
+
return if installer.status.is_a?(Process::Status) && installer.status.success?
|
|
136
|
+
|
|
137
|
+
raise Error, "#{manager} install failed (#{installer.describe_status}) in #{frontend_dir}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def package_manager
|
|
141
|
+
{
|
|
142
|
+
"pnpm-lock.yaml" => "pnpm", "yarn.lock" => "yarn", "bun.lockb" => "bun", "bun.lock" => "bun"
|
|
143
|
+
}.each { |lock, manager| return manager if frontend_dir.join(lock).exist? }
|
|
144
|
+
"npm"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def banner
|
|
148
|
+
url = "http://localhost:#{@gateway.port}"
|
|
149
|
+
t = @terminal
|
|
150
|
+
t.puts
|
|
151
|
+
t.puts(" #{t.bold("GemStack")} #{t.dim("v#{GemStack::VERSION} · #{@env.fetch("GEMSTACK_ENV",
|
|
152
|
+
"development")}")}")
|
|
153
|
+
t.puts
|
|
154
|
+
split = t.dim("(#{@config.http.api_path}/* → Ruby, everything else → Next.js)")
|
|
155
|
+
t.puts(" #{t.green("✓")} Gateway #{url} #{split}")
|
|
156
|
+
t.puts(" #{t.yellow("…")} Ruby API #{t.dim("starting on #{@gateway.api} (internal)")}")
|
|
157
|
+
if @gateway.frontend
|
|
158
|
+
t.puts(" #{t.yellow("…")} Next.js #{t.dim("starting on #{@gateway.frontend} (internal)")}")
|
|
159
|
+
else
|
|
160
|
+
t.puts(" #{t.dim("-")} Next.js #{t.dim("no #{@dev.frontend_dir}/package.json — API only")}")
|
|
161
|
+
end
|
|
162
|
+
t.puts
|
|
163
|
+
t.puts(" Application: #{t.bold(url)}")
|
|
164
|
+
t.puts
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def watch_readiness
|
|
168
|
+
[@gateway.api, @gateway.frontend].compact.each do |upstream|
|
|
169
|
+
Thread.new do
|
|
170
|
+
until @stopping
|
|
171
|
+
if Ports.open?(upstream.host, upstream.port)
|
|
172
|
+
seconds = (monotonic - @started_at).round(1)
|
|
173
|
+
@terminal.line("gemstack", "#{@terminal.green("✓")} #{upstream.label} ready (#{seconds}s)")
|
|
174
|
+
break
|
|
175
|
+
end
|
|
176
|
+
sleep TICK
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def install_signal_handlers
|
|
183
|
+
%w[INT TERM].each { |signal| trap(signal) { @stopping = true } }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def loop_until_stopped
|
|
187
|
+
until @stopping
|
|
188
|
+
sleep TICK
|
|
189
|
+
supervise
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def supervise
|
|
194
|
+
api = @processes[:api]
|
|
195
|
+
if @restart_watcher.changed?
|
|
196
|
+
restart_api("configuration changed")
|
|
197
|
+
elsif !api.running? && api.exited?
|
|
198
|
+
report_exit(api)
|
|
199
|
+
restart_api("files changed") if @recovery_watcher.changed?
|
|
200
|
+
end
|
|
201
|
+
web = @processes[:next]
|
|
202
|
+
report_exit(web) if web && !web.running? && web.exited?
|
|
203
|
+
supervise_contract
|
|
204
|
+
supervise_jobs
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# A worker only makes sense with the database queue and its table.
|
|
208
|
+
def jobs_enabled?
|
|
209
|
+
return false unless @dev.jobs_command && defined?(GemStack::Jobs) && @config.respond_to?(:jobs)
|
|
210
|
+
return false unless %w[database postgres].include?(@config.jobs.adapter.to_s)
|
|
211
|
+
|
|
212
|
+
Dir.glob(@root.join("db/migrations/*_create_gemstack_jobs.rb").to_s).any?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def start_jobs
|
|
216
|
+
@processes[:jobs] = spawn("jobs", @dev.jobs_command, @root, { "GEMSTACK_ENV" => "development" })
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The worker doesn't reload code, so restart it when app/ changes
|
|
220
|
+
# (it finishes running jobs first), and start it once the jobs table exists.
|
|
221
|
+
def supervise_jobs
|
|
222
|
+
return unless @jobs_watcher&.changed?
|
|
223
|
+
|
|
224
|
+
jobs = @processes[:jobs]
|
|
225
|
+
if jobs&.running?
|
|
226
|
+
@terminal.line("gemstack", "app changed — restarting job worker")
|
|
227
|
+
jobs.restart
|
|
228
|
+
elsif jobs_enabled?
|
|
229
|
+
start_jobs
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# One contract run at a time; changes during a run queue exactly one more.
|
|
234
|
+
def supervise_contract
|
|
235
|
+
return unless @contract_watcher
|
|
236
|
+
|
|
237
|
+
@contract_pending = true if @contract_watcher.changed?
|
|
238
|
+
running = @processes[:contract]&.running?
|
|
239
|
+
return if running || !@contract_pending
|
|
240
|
+
|
|
241
|
+
@contract_pending = false
|
|
242
|
+
@processes[:contract] = spawn("contract", @dev.contract_command, @root, { "GEMSTACK_ENV" => "development" })
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Both watchers overlap (config/), so re-baseline both after a restart
|
|
246
|
+
# to avoid restarting twice for a single change.
|
|
247
|
+
def restart_api(reason)
|
|
248
|
+
@terminal.line("gemstack", "#{reason} — restarting Ruby API")
|
|
249
|
+
@processes[:api].restart
|
|
250
|
+
@restart_watcher.changed?
|
|
251
|
+
@recovery_watcher.changed?
|
|
252
|
+
@reported_exit = nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def report_exit(process)
|
|
256
|
+
key = [process.name, process.pid]
|
|
257
|
+
return if @reported_exit&.include?(key)
|
|
258
|
+
|
|
259
|
+
(@reported_exit ||= []) << key
|
|
260
|
+
hint = process.name == "api" ? " — fix the error; it restarts when you save a file" : ""
|
|
261
|
+
@terminal.line("gemstack", @terminal.red("#{process.name} exited (#{process.describe_status})#{hint}"))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Dev
|
|
5
|
+
# Serialised, prefixed, coloured output for multiple processes:
|
|
6
|
+
#
|
|
7
|
+
# api │ 12:00:01.120 INFO GET /api/health status=200 ms=0.4
|
|
8
|
+
# next │ ✓ Compiled / in 812ms
|
|
9
|
+
class Terminal
|
|
10
|
+
COLORS = { "api" => 35, "next" => 36, "gateway" => 33, "gemstack" => 32, "jobs" => 34 }.freeze
|
|
11
|
+
WIDTH = 8
|
|
12
|
+
|
|
13
|
+
def initialize(io = $stdout, color: io.respond_to?(:tty?) && io.tty?)
|
|
14
|
+
@io = io
|
|
15
|
+
@io.sync = true if @io.respond_to?(:sync=) # output may be a pipe or file
|
|
16
|
+
@color = color
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :color
|
|
21
|
+
|
|
22
|
+
def line(name, text)
|
|
23
|
+
prefix = paint(name.ljust(WIDTH), COLORS.fetch(name, 37))
|
|
24
|
+
text = text.to_s.chomp
|
|
25
|
+
@mutex.synchronize { @io.puts("#{prefix}│ #{text}") }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def puts(text = "")
|
|
29
|
+
@mutex.synchronize { @io.puts(text) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def paint(text, code) = @color ? "\e[#{code}m#{text}\e[0m" : text
|
|
33
|
+
def bold(text) = @color ? "\e[1m#{text}\e[0m" : text
|
|
34
|
+
def dim(text) = paint(text, 90)
|
|
35
|
+
def green(text) = paint(text, 32)
|
|
36
|
+
def red(text) = paint(text, 31)
|
|
37
|
+
def yellow(text) = paint(text, 33)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module GemStack
|
|
7
|
+
module Dev
|
|
8
|
+
# The Ruby and Node.js a developer runs, and how to change them — for
|
|
9
|
+
# whichever version manager installed them (rbenv, rvm, asdf, mise,
|
|
10
|
+
# chruby, nvm, fnm, nodenv, Volta, Homebrew…). GemStack pins versions in
|
|
11
|
+
# files every manager reads (.ruby-version, .node-version, .nvmrc,
|
|
12
|
+
# .tool-versions) and never assumes one manager.
|
|
13
|
+
module Toolchain
|
|
14
|
+
MIN_RUBY = "3.3"
|
|
15
|
+
MIN_NODE = [20, 9].freeze # Next.js 16
|
|
16
|
+
LTS_NODE = "22"
|
|
17
|
+
|
|
18
|
+
# [pattern in the executable's path, manager name]; the first match wins.
|
|
19
|
+
RUBY_MANAGERS = [
|
|
20
|
+
[%r{/\.rbenv/}, :rbenv], [%r{/\.rvm/|/rvm/rubies/}, :rvm], [%r{/\.asdf/|/asdf/installs/}, :asdf],
|
|
21
|
+
[%r{/mise/installs/|/\.local/share/mise/}, :mise], [%r{/\.rubies/|/opt/rubies/}, :chruby],
|
|
22
|
+
[%r{/(?:opt/)?homebrew/|/usr/local/Cellar/}, :homebrew]
|
|
23
|
+
].freeze
|
|
24
|
+
NODE_MANAGERS = [
|
|
25
|
+
[%r{/\.nvm/}, :nvm], [%r{/fnm/|/\.fnm/|fnm_multishells}, :fnm], [%r{/\.nodenv/}, :nodenv],
|
|
26
|
+
[%r{/\.asdf/|/asdf/installs/}, :asdf], [%r{/mise/installs/|/\.local/share/mise/}, :mise],
|
|
27
|
+
[%r{/\.volta/}, :volta], [%r{/(?:opt/)?homebrew/|/usr/local/Cellar/}, :homebrew]
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def ruby_manager(path = RbConfig.ruby) = detect(RUBY_MANAGERS, path)
|
|
33
|
+
def node_manager(path) = path && detect(NODE_MANAGERS, path)
|
|
34
|
+
|
|
35
|
+
def detect(managers, path)
|
|
36
|
+
managers.find { |pattern, _| path.to_s.match?(pattern) }&.last
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# How to install and select a Ruby, with the manager in use.
|
|
40
|
+
def ruby_hint(version, manager = ruby_manager)
|
|
41
|
+
{
|
|
42
|
+
rbenv: "rbenv install #{version} && rbenv local #{version}",
|
|
43
|
+
rvm: "rvm install #{version} && rvm use #{version}",
|
|
44
|
+
asdf: "asdf install ruby #{version} && asdf set ruby #{version}",
|
|
45
|
+
mise: "mise use ruby@#{version}",
|
|
46
|
+
chruby: "ruby-install ruby #{version}, then: chruby #{version}",
|
|
47
|
+
homebrew: "brew upgrade ruby (or install #{version} with rbenv, rvm, asdf or mise)"
|
|
48
|
+
}.fetch(manager, "install Ruby #{version} (e.g. with rbenv, rvm, asdf or mise) and make it the active Ruby")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# How to install and select a Node.js, with the manager in use.
|
|
52
|
+
def node_hint(version, manager)
|
|
53
|
+
{
|
|
54
|
+
nvm: "nvm install #{version} && nvm use #{version}",
|
|
55
|
+
fnm: "fnm install #{version} && fnm use #{version}",
|
|
56
|
+
nodenv: "nodenv install #{version} && nodenv local #{version}",
|
|
57
|
+
asdf: "asdf install nodejs #{version} && asdf set nodejs #{version}",
|
|
58
|
+
mise: "mise use node@#{version}",
|
|
59
|
+
volta: "volta install node@#{version}",
|
|
60
|
+
homebrew: "brew install node@#{version} (or use nvm, fnm, asdf or mise)"
|
|
61
|
+
}.fetch(manager, "install Node.js #{version} (e.g. with nvm, fnm, asdf or mise) and make it the active node")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# { version: "22.11.0", path: "/…/bin/node" } or nil when there's no node.
|
|
65
|
+
def node(run = ->(*cmd) { Open3.capture2e(*cmd) })
|
|
66
|
+
out, status = run.call("node", "-p", "process.version + ' ' + process.execPath")
|
|
67
|
+
return nil unless status.success?
|
|
68
|
+
|
|
69
|
+
version, path = out.strip.split(" ", 2)
|
|
70
|
+
{ version: version.delete_prefix("v"), path: path }
|
|
71
|
+
rescue SystemCallError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def node_ok?(version) = (version.to_s.split(".").map(&:to_i) <=> MIN_NODE) >= 0
|
|
76
|
+
def ruby_ok?(version = RUBY_VERSION) = Gem::Version.new(version) >= Gem::Version.new(MIN_RUBY)
|
|
77
|
+
|
|
78
|
+
# The Node.js version new apps pin: the one running `gemstack new` when
|
|
79
|
+
# it's new enough, else the current LTS line.
|
|
80
|
+
def pinned_node_version(run = ->(*cmd) { Open3.capture2e(*cmd) })
|
|
81
|
+
found = node(run)
|
|
82
|
+
found && node_ok?(found[:version]) ? found[:version] : LTS_NODE
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/gemstack/dev.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/core"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
# Development tooling: the single-origin gateway and the process
|
|
7
|
+
# supervisor behind `gemstack dev` (ARCHITECTURE §5). Nothing here is
|
|
8
|
+
# loaded in production unless explicitly required.
|
|
9
|
+
module Dev
|
|
10
|
+
autoload :FileWatcher, "gemstack/dev/file_watcher"
|
|
11
|
+
autoload :Gateway, "gemstack/dev/gateway"
|
|
12
|
+
autoload :ManagedProcess, "gemstack/dev/managed_process"
|
|
13
|
+
autoload :Ports, "gemstack/dev/ports"
|
|
14
|
+
autoload :Supervisor, "gemstack/dev/supervisor"
|
|
15
|
+
autoload :Terminal, "gemstack/dev/terminal"
|
|
16
|
+
autoload :Toolchain, "gemstack/dev/toolchain"
|
|
17
|
+
|
|
18
|
+
class Config < Settings
|
|
19
|
+
# The single public port.
|
|
20
|
+
setting :port, default: -> { Integer(ENV.fetch("PORT", 3000)) }
|
|
21
|
+
# Interfaces the gateway listens on. Both loopback families, because
|
|
22
|
+
# browsers may resolve "localhost" to either.
|
|
23
|
+
setting :bind, default: %w[127.0.0.1 ::1]
|
|
24
|
+
setting :frontend_dir, default: "frontend"
|
|
25
|
+
setting :api_command, default: %w[bundle exec puma -C config/puma.rb]
|
|
26
|
+
# nil = run the frontend's own `next` binary in dev mode.
|
|
27
|
+
setting :frontend_command, default: nil
|
|
28
|
+
# Changes here restart the Ruby process. app/ and config/routes.rb are
|
|
29
|
+
# reloaded in-process instead and don't need a restart.
|
|
30
|
+
setting :restart_on, default: ["config/**/*.rb", "Gemfile.lock", ".env*"]
|
|
31
|
+
setting :restart_exclude, default: ["config/routes.rb"]
|
|
32
|
+
# Regenerates TypeScript types/clients when backend code changes
|
|
33
|
+
# (only when the app has a frontend). nil disables.
|
|
34
|
+
setting :contract_command, default: %w[bundle exec gemstack contract --quiet]
|
|
35
|
+
setting :contract_watch, default: ["app/**/*.rb", "config/routes.rb"]
|
|
36
|
+
# Background job worker, run when the app uses the :postgres job
|
|
37
|
+
# adapter and has the gemstack_jobs migration. Restarted when app/ changes.
|
|
38
|
+
setting :jobs_command, default: %w[bundle exec gemstack jobs]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Config.namespace(:dev, Dev::Config)
|
|
43
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Minimal `.env` file support.
|
|
5
|
+
#
|
|
6
|
+
# Supported syntax:
|
|
7
|
+
# KEY=value
|
|
8
|
+
# export KEY=value
|
|
9
|
+
# KEY="double quoted, supports \n \t \" escapes"
|
|
10
|
+
# KEY='single quoted, literal'
|
|
11
|
+
# KEY=value # trailing comment (unquoted values only)
|
|
12
|
+
# # full-line comment
|
|
13
|
+
#
|
|
14
|
+
# Variables already present in the environment are never overwritten: the
|
|
15
|
+
# real environment always wins over files.
|
|
16
|
+
module Dotenv
|
|
17
|
+
LINE = /\A\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)\z/
|
|
18
|
+
ESCAPES = { "n" => "\n", "t" => "\t", "r" => "\r", '"' => '"', "\\" => "\\" }.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Loads the given files (in order; earlier files take precedence) into env.
|
|
23
|
+
# Returns the hash of variables that were actually set.
|
|
24
|
+
def load(*files, env: ENV)
|
|
25
|
+
loaded = {}
|
|
26
|
+
files.flatten.each do |file|
|
|
27
|
+
next unless File.file?(file)
|
|
28
|
+
|
|
29
|
+
parse(File.read(file)).each do |key, value|
|
|
30
|
+
next if env.key?(key) || loaded.key?(key)
|
|
31
|
+
|
|
32
|
+
env[key] = value
|
|
33
|
+
loaded[key] = value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
loaded
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse(source)
|
|
40
|
+
source.each_line.with_object({}) do |raw, vars|
|
|
41
|
+
line = raw.chomp
|
|
42
|
+
next if line.strip.empty? || line.lstrip.start_with?("#")
|
|
43
|
+
|
|
44
|
+
match = LINE.match(line) or next
|
|
45
|
+
vars[match[1]] = parse_value(match[2].strip)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_value(value)
|
|
50
|
+
case value[0]
|
|
51
|
+
when '"'
|
|
52
|
+
body = value[1..][/\A((?:[^"\\]|\\.)*)"/, 1] || value[1..]
|
|
53
|
+
body.gsub(/\\(.)/) { ESCAPES.fetch(::Regexp.last_match(1), "\\#{::Regexp.last_match(1)}") }
|
|
54
|
+
when "'"
|
|
55
|
+
value[1..][/\A([^']*)'/, 1] || value[1..]
|
|
56
|
+
else
|
|
57
|
+
value.sub(/\s+#.*\z/, "")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# The running environment: development, test, production, or any custom name.
|
|
5
|
+
#
|
|
6
|
+
# GemStack.env.production? # => false
|
|
7
|
+
# GemStack.env.to_s # => "development"
|
|
8
|
+
class Environment
|
|
9
|
+
KNOWN = %w[development test production].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def self.detect(env = ENV)
|
|
14
|
+
new(env["GEMSTACK_ENV"] || env["RACK_ENV"] || "development")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(name)
|
|
18
|
+
@name = name.to_s.strip.downcase
|
|
19
|
+
raise ArgumentError, "environment name cannot be empty" if @name.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def development? = name == "development"
|
|
23
|
+
def test? = name == "test"
|
|
24
|
+
def production? = name == "production"
|
|
25
|
+
|
|
26
|
+
# Anything that isn't development or test is treated like production for
|
|
27
|
+
# safety-related defaults (hiding error details, JSON logs, ...).
|
|
28
|
+
def local? = development? || test?
|
|
29
|
+
|
|
30
|
+
def to_s = name
|
|
31
|
+
def to_sym = name.to_sym
|
|
32
|
+
def ==(other) = name == other.to_s
|
|
33
|
+
alias eql? ==
|
|
34
|
+
def hash = name.hash
|
|
35
|
+
def inspect = "#<GemStack::Environment #{name}>"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Translates third-party exceptions into GemStack errors, so modules can
|
|
5
|
+
# give library errors an HTTP meaning without depending on the HTTP layer:
|
|
6
|
+
#
|
|
7
|
+
# GemStack::ErrorMapping.register(Sequel::NoMatchingRow) { GemStack::NotFound.new }
|
|
8
|
+
# GemStack::ErrorMapping.register(Stripe::CardError) { |e| GemStack::Error.new(e.message, status: 402, code: "card_declined") }
|
|
9
|
+
#
|
|
10
|
+
# The HTTP error renderer consults this registry before rendering. The most
|
|
11
|
+
# recently registered matching class wins, so applications can override
|
|
12
|
+
# module defaults.
|
|
13
|
+
module ErrorMapping
|
|
14
|
+
@mappings = []
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def register(exception_class, &translator)
|
|
19
|
+
raise ArgumentError, "ErrorMapping.register needs a block" unless translator
|
|
20
|
+
|
|
21
|
+
@mutex.synchronize { @mappings.unshift([exception_class, translator]) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unregister(exception_class)
|
|
25
|
+
@mutex.synchronize { @mappings.reject! { |klass, _| klass == exception_class } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the translated error, or the original exception if no mapping applies.
|
|
29
|
+
def translate(exception)
|
|
30
|
+
_, translator = @mappings.find { |klass, _| exception.is_a?(klass) }
|
|
31
|
+
return exception unless translator
|
|
32
|
+
|
|
33
|
+
translator.call(exception) || exception
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Base class for every GemStack error.
|
|
5
|
+
#
|
|
6
|
+
# Errors carry an HTTP-oriented *suggestion* (`status`, `code`) so that any
|
|
7
|
+
# module — including ones that know nothing about HTTP, such as a database
|
|
8
|
+
# adapter — can raise an error that the HTTP layer renders correctly. Any
|
|
9
|
+
# exception that responds to #status and #code is rendered this way, so
|
|
10
|
+
# applications can define their own errors without subclassing these.
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
class << self
|
|
13
|
+
attr_writer :default_status, :default_code, :default_message
|
|
14
|
+
|
|
15
|
+
def default_status = @default_status || inherited_default(:default_status, 500)
|
|
16
|
+
def default_code = @default_code || inherited_default(:default_code, "internal_error")
|
|
17
|
+
def default_message = @default_message || inherited_default(:default_message, "Internal Server Error")
|
|
18
|
+
|
|
19
|
+
# Declares the status/code/message for an error class.
|
|
20
|
+
def status(status, code, message)
|
|
21
|
+
self.default_status = status
|
|
22
|
+
self.default_code = code
|
|
23
|
+
self.default_message = message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def inherited_default(name, fallback) = superclass.respond_to?(name) ? superclass.public_send(name) : fallback
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :status, :code, :details, :headers
|
|
32
|
+
|
|
33
|
+
# details: optional field => [messages] hash, rendered as `errors`.
|
|
34
|
+
# headers: extra response headers (e.g. "allow" for 405, "retry-after" for 429).
|
|
35
|
+
def initialize(message = nil, status: nil, code: nil, details: nil, headers: nil)
|
|
36
|
+
@status = status || self.class.default_status
|
|
37
|
+
@code = code || self.class.default_code
|
|
38
|
+
@details = details
|
|
39
|
+
@headers = headers || {}
|
|
40
|
+
super(message || self.class.default_message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether the message is safe to show to API clients. Server errors are
|
|
44
|
+
# not: their messages may contain internal information.
|
|
45
|
+
def expose_message? = status < 500
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raised for invalid framework or application configuration.
|
|
49
|
+
class ConfigurationError < Error; end
|
|
50
|
+
|
|
51
|
+
class BadRequest < Error
|
|
52
|
+
status 400, "bad_request", "Bad Request"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Unauthorized < Error
|
|
56
|
+
status 401, "unauthorized", "Unauthorized"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Forbidden < Error
|
|
60
|
+
status 403, "forbidden", "Forbidden"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class NotFound < Error
|
|
64
|
+
status 404, "not_found", "Not Found"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class MethodNotAllowed < Error
|
|
68
|
+
status 405, "method_not_allowed", "Method Not Allowed"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class Conflict < Error
|
|
72
|
+
status 409, "conflict", "Conflict"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class PayloadTooLarge < Error
|
|
76
|
+
status 413, "payload_too_large", "Payload Too Large"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class UnsupportedMediaType < Error
|
|
80
|
+
status 415, "unsupported_media_type", "Unsupported Media Type"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class ValidationError < Error
|
|
84
|
+
status 422, "validation_failed", "Validation failed"
|
|
85
|
+
|
|
86
|
+
def initialize(message = nil, errors: {}, **)
|
|
87
|
+
super(message, details: errors, **)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def errors = details
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class TooManyRequests < Error
|
|
94
|
+
status 429, "too_many_requests", "Too Many Requests"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class ServiceUnavailable < Error
|
|
98
|
+
status 503, "service_unavailable", "Service Unavailable"
|
|
99
|
+
end
|
|
100
|
+
end
|