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,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Contract
|
|
5
|
+
# Emits TypeScript from the contract IR:
|
|
6
|
+
# types.ts one `export type` per serializer / input schema
|
|
7
|
+
# <resource>.ts a typed client object per controller
|
|
8
|
+
# index.ts re-exports everything
|
|
9
|
+
#
|
|
10
|
+
# Shapes are type aliases (not interfaces) so they are assignable to the
|
|
11
|
+
# client's Query record type.
|
|
12
|
+
class TypeScript
|
|
13
|
+
def initialize(contract, client_import: "@/lib/gemstack/client")
|
|
14
|
+
@contract = contract
|
|
15
|
+
@client_import = client_import
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def files
|
|
19
|
+
files = { "types.ts" => types_file }
|
|
20
|
+
@contract[:resources].each { |resource| files["#{file_base(resource)}.ts"] = resource_file(resource) }
|
|
21
|
+
files["index.ts"] = index_file
|
|
22
|
+
files
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
PAGE_TYPES = <<~TS
|
|
26
|
+
/** The pagination envelope returned by `paginate` in controllers. */
|
|
27
|
+
export type PaginationMeta = {
|
|
28
|
+
page: number;
|
|
29
|
+
per_page: number;
|
|
30
|
+
total: number;
|
|
31
|
+
total_pages: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type Paginated<T> = {
|
|
35
|
+
data: T[];
|
|
36
|
+
meta: PaginationMeta;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type PaginationQuery = {
|
|
40
|
+
page?: number;
|
|
41
|
+
per_page?: number;
|
|
42
|
+
};
|
|
43
|
+
TS
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def header = "// #{HEADER}\n"
|
|
48
|
+
|
|
49
|
+
def types_file
|
|
50
|
+
body = @contract[:types].map do |name, type|
|
|
51
|
+
"export type #{name} = #{object_type(type[:fields], 0)};\n"
|
|
52
|
+
end
|
|
53
|
+
body.unshift(PAGE_TYPES) if paginated?
|
|
54
|
+
# No types yet (a new app): still a module, or `export type * from "./types"` fails to compile.
|
|
55
|
+
body << "export {};\n" if body.empty?
|
|
56
|
+
"#{header}\n#{body.join("\n")}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def paginated? = @contract[:resources].any? { |r| r[:endpoints].any? { |e| e[:paginated] } }
|
|
60
|
+
|
|
61
|
+
def object_type(fields, depth)
|
|
62
|
+
return "Record<string, never>" if fields.empty?
|
|
63
|
+
|
|
64
|
+
pad = " " * (depth + 1)
|
|
65
|
+
lines = fields.map do |field|
|
|
66
|
+
type = ts_type(field[:type], depth + 1)
|
|
67
|
+
type = "#{type} | null" if field[:nullable]
|
|
68
|
+
"#{pad}#{property(field[:name])}#{"?" if field[:optional]}: #{type};"
|
|
69
|
+
end
|
|
70
|
+
"{\n#{lines.join("\n")}\n#{" " * depth}}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ts_type(ref, depth = 0)
|
|
74
|
+
if ref[:scalar] then Types.fetch(ref[:scalar]).ts
|
|
75
|
+
elsif ref[:ref] then ref[:ref]
|
|
76
|
+
elsif ref[:array]
|
|
77
|
+
inner = ts_type(ref[:array], depth)
|
|
78
|
+
inner.match?(/\A[\w.]+\z/) ? "#{inner}[]" : "Array<#{inner}>"
|
|
79
|
+
elsif ref[:page] then "Paginated<#{ts_type(ref[:page], depth)}>"
|
|
80
|
+
elsif ref[:object] then object_type(ref[:object], depth)
|
|
81
|
+
else "unknown"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def property(name) = name.match?(/\A[A-Za-z_$][\w$]*\z/) ? name : name.inspect
|
|
86
|
+
|
|
87
|
+
def resource_file(resource)
|
|
88
|
+
refs = []
|
|
89
|
+
methods = resource[:endpoints].map { |endpoint| client_method(endpoint, refs) }
|
|
90
|
+
imports = refs.uniq.sort
|
|
91
|
+
lines = [header]
|
|
92
|
+
lines << %(import { api, type RequestOptions } from "#{@client_import}";)
|
|
93
|
+
lines << %(import type { #{imports.join(", ")} } from "./types";) unless imports.empty?
|
|
94
|
+
lines << ""
|
|
95
|
+
lines << "const segment = (value: string | number) => encodeURIComponent(String(value));"
|
|
96
|
+
lines << ""
|
|
97
|
+
lines << "export const #{export_name(resource)} = {"
|
|
98
|
+
lines.concat(methods)
|
|
99
|
+
lines << "};"
|
|
100
|
+
"#{lines.join("\n")}\n"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def client_method(endpoint, refs)
|
|
104
|
+
args = endpoint[:params].map { |param| "#{camel(param)}: string | number" }
|
|
105
|
+
collect_refs(endpoint[:body], refs)
|
|
106
|
+
collect_refs(endpoint[:query], refs)
|
|
107
|
+
collect_refs(endpoint[:response], refs)
|
|
108
|
+
args << "data: #{ts_type(endpoint[:body])}" if endpoint[:body]
|
|
109
|
+
args << query_arg(endpoint) if endpoint[:query] || endpoint[:paginated]
|
|
110
|
+
refs << "PaginationQuery" if endpoint[:paginated]
|
|
111
|
+
args << "options?: RequestOptions"
|
|
112
|
+
response = endpoint[:response] ? ts_type(endpoint[:response]) : "void"
|
|
113
|
+
call = "api.#{client_verb(endpoint[:verb])}<#{response}>(#{call_args(endpoint)})"
|
|
114
|
+
" /** #{endpoint[:verb]} #{@contract[:api_path]}#{endpoint[:path]} */\n " \
|
|
115
|
+
"#{endpoint[:name]}: (#{args.join(", ")}) => #{call},"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Paginated lists take an optional { page, per_page } query, combined
|
|
119
|
+
# with the action's own query schema when there is one.
|
|
120
|
+
def query_arg(endpoint)
|
|
121
|
+
return "query: #{ts_type(endpoint[:query])}" unless endpoint[:paginated]
|
|
122
|
+
return "query?: PaginationQuery" unless endpoint[:query]
|
|
123
|
+
|
|
124
|
+
"query: #{ts_type(endpoint[:query])} & PaginationQuery"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def call_args(endpoint)
|
|
128
|
+
path = endpoint[:path].gsub(/[:*](\w+)/) { "${segment(#{camel(::Regexp.last_match(1))})}" }
|
|
129
|
+
path = path.include?("${") ? "`#{path}`" : path.inspect
|
|
130
|
+
options = endpoint[:query] || endpoint[:paginated] ? "{ ...options, query }" : "options"
|
|
131
|
+
case client_verb(endpoint[:verb])
|
|
132
|
+
when "get", "delete" then "#{path}, #{options}"
|
|
133
|
+
else "#{path}, #{endpoint[:body] ? "data" : "undefined"}, #{options}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def client_verb(verb) = { "HEAD" => "get", "OPTIONS" => "get" }.fetch(verb, verb.downcase)
|
|
138
|
+
|
|
139
|
+
def collect_refs(ref, refs)
|
|
140
|
+
return unless ref
|
|
141
|
+
|
|
142
|
+
refs << ref[:ref] if ref[:ref]
|
|
143
|
+
refs << "Paginated" if ref[:page]
|
|
144
|
+
collect_refs(ref[:array], refs) if ref[:array]
|
|
145
|
+
collect_refs(ref[:page], refs) if ref[:page]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def index_file
|
|
149
|
+
exports = @contract[:resources].map { |r| %(export { #{export_name(r)} } from "./#{file_base(r)}";) }
|
|
150
|
+
"#{header}\nexport type * from \"./types\";\n#{exports.join("\n")}\n"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def file_base(resource) = resource[:name].tr("/", "_")
|
|
154
|
+
def export_name(resource) = camel(resource[:name].tr("/", "_"))
|
|
155
|
+
|
|
156
|
+
def camel(name)
|
|
157
|
+
camel = Inflector.camelize(name)
|
|
158
|
+
camel[0].downcase + camel[1..]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "gemstack/core"
|
|
6
|
+
require "gemstack/schema"
|
|
7
|
+
require "gemstack/http"
|
|
8
|
+
|
|
9
|
+
module GemStack
|
|
10
|
+
# The API contract: derived from the backend (routes + controller `accepts`
|
|
11
|
+
# schemas + serializers) and emitted as TypeScript types, a typed client
|
|
12
|
+
# per resource, and OpenAPI 3.1 (ARCHITECTURE §8).
|
|
13
|
+
#
|
|
14
|
+
# contract = GemStack::Contract.build(GemStack.boot!)
|
|
15
|
+
# GemStack::Contract.write(contract, root: GemStack.root)
|
|
16
|
+
module Contract
|
|
17
|
+
class Config < Settings
|
|
18
|
+
setting :output_dir, default: "frontend/lib/api/generated"
|
|
19
|
+
setting :openapi_path, default: "openapi.json"
|
|
20
|
+
# Import path of the client runtime from generated files.
|
|
21
|
+
setting :client_import, default: "@/lib/gemstack/client"
|
|
22
|
+
# Interactive API docs at <api_path>/docs, built from the live routes (development only by default).
|
|
23
|
+
setting :docs, default: -> { GemStack.env.development? }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
HEADER = "Generated by GemStack from the backend — do not edit. Run `gemstack contract` to regenerate."
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def config = GemStack.config.contract
|
|
30
|
+
|
|
31
|
+
# Builds the contract IR from a booted application (eager-loads code).
|
|
32
|
+
def build(app)
|
|
33
|
+
app.eager_load!
|
|
34
|
+
Builder.new(routes: app.routes, api_path: app.config.http.api_path).build
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Writes TypeScript and OpenAPI files, touching only files whose content
|
|
38
|
+
# changed (so Next.js doesn't hot-reload needlessly) and removing
|
|
39
|
+
# generated files for resources that no longer exist. Returns
|
|
40
|
+
# { written: [...], removed: [...], unchanged: [...] }. dry_run: true
|
|
41
|
+
# reports what would change without touching anything (`gemstack doctor`).
|
|
42
|
+
def write(contract, root:, output_dir: config.output_dir, openapi_path: config.openapi_path, typescript: true,
|
|
43
|
+
dry_run: false)
|
|
44
|
+
dir = File.expand_path(output_dir, root)
|
|
45
|
+
files = typescript ? TypeScript.new(contract, client_import: config.client_import).files : {}
|
|
46
|
+
report = { written: [], removed: [], unchanged: [] }
|
|
47
|
+
files.each { |name, content| write_file(File.join(dir, name), content, report, dry_run: dry_run) }
|
|
48
|
+
stale = typescript ? Dir.glob(File.join(dir, "*.ts")) : []
|
|
49
|
+
stale = stale.reject { |path| files.key?(File.basename(path)) }
|
|
50
|
+
stale.each do |path|
|
|
51
|
+
next unless File.read(path).include?(HEADER)
|
|
52
|
+
|
|
53
|
+
File.delete(path) unless dry_run
|
|
54
|
+
report[:removed] << path
|
|
55
|
+
end
|
|
56
|
+
if openapi_path
|
|
57
|
+
write_file(File.expand_path(openapi_path, root), "#{JSON.pretty_generate(OpenAPI.new(contract).document)}\n",
|
|
58
|
+
report, dry_run: dry_run)
|
|
59
|
+
end
|
|
60
|
+
report
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def write_file(path, content, report, dry_run: false)
|
|
66
|
+
if File.exist?(path) && File.read(path) == content
|
|
67
|
+
report[:unchanged] << path
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
return report[:written] << path if dry_run
|
|
71
|
+
|
|
72
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
73
|
+
File.write(path, content)
|
|
74
|
+
report[:written] << path
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Config.namespace(:contract, Contract::Config)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
require_relative "contract/builder"
|
|
83
|
+
require_relative "contract/typescript"
|
|
84
|
+
require_relative "contract/openapi"
|
|
85
|
+
require_relative "contract/docs"
|
|
86
|
+
|
|
87
|
+
GemStack::Plugins.register(:contract_docs) do |app|
|
|
88
|
+
next unless app.respond_to?(:routes) && app.config.contract.docs
|
|
89
|
+
|
|
90
|
+
stack = app.config.http.middleware
|
|
91
|
+
stack.insert_before(GemStack::HTTP::Middleware::HealthCheck, GemStack::Contract::Docs, app) unless
|
|
92
|
+
stack.include?(GemStack::Contract::Docs)
|
|
93
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "version"
|
|
7
|
+
require_relative "settings"
|
|
8
|
+
require_relative "environment"
|
|
9
|
+
require_relative "dotenv"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "error_mapping"
|
|
12
|
+
require_relative "logger"
|
|
13
|
+
require_relative "inflector"
|
|
14
|
+
require_relative "plugins"
|
|
15
|
+
|
|
16
|
+
module GemStack
|
|
17
|
+
# Root configuration. Modules add namespaces to it:
|
|
18
|
+
# GemStack::Config.namespace(:http, GemStack::HTTP::Config)
|
|
19
|
+
class Config < Settings
|
|
20
|
+
setting :name, default: -> { File.basename(root) }
|
|
21
|
+
setting :root, default: -> { Dir.pwd }
|
|
22
|
+
|
|
23
|
+
# .env files loaded at boot, earlier files win. Real ENV always wins.
|
|
24
|
+
setting :env_files, default: lambda {
|
|
25
|
+
env = GemStack.env
|
|
26
|
+
env.local? ? [".env.#{env}.local", ".env.local", ".env.#{env}", ".env"] : []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Root secret for signatures (storage URLs, derived keys). Production must
|
|
30
|
+
# set SECRET_KEY_BASE; development/test generate one in tmp/ (git-ignored).
|
|
31
|
+
setting :secret_key_base, default: lambda {
|
|
32
|
+
ENV.fetch("SECRET_KEY_BASE", nil) || (GemStack.env.local? ? GemStack.send(:local_secret) : nil)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Keys (substring, case-insensitive) masked in logs and error output.
|
|
36
|
+
setting :filter_parameters, default: %w[password passwd secret token api_key apikey authorization cookie
|
|
37
|
+
credit_card card_number cvv ssn private_key]
|
|
38
|
+
|
|
39
|
+
namespace :logger do
|
|
40
|
+
setting :level, default: -> { ENV.fetch("GEMSTACK_LOG_LEVEL") { GemStack.env.production? ? "info" : "debug" } }
|
|
41
|
+
setting :format, default: -> { GemStack.env.local? ? :pretty : :json }
|
|
42
|
+
# Test logs are discarded unless GEMSTACK_LOG_LEVEL is set explicitly.
|
|
43
|
+
setting :output, default: -> { GemStack.env.test? && !ENV["GEMSTACK_LOG_LEVEL"] ? nil : $stdout }
|
|
44
|
+
# nil = colour when output is a terminal. `gemstack dev` sets
|
|
45
|
+
# GEMSTACK_LOG_COLOR=1 because child output goes through a pipe.
|
|
46
|
+
setting :color, default: -> { ENV["GEMSTACK_LOG_COLOR"]&.then { |v| v == "1" } }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def config
|
|
52
|
+
@config ||= Config.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def configure
|
|
56
|
+
yield config
|
|
57
|
+
config
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def env
|
|
61
|
+
@env ||= Environment.detect
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def env=(name)
|
|
65
|
+
@env = name.is_a?(Environment) ? name : Environment.new(name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def root
|
|
69
|
+
Pathname.new(config.root)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# First call in config/app.rb: sets the application root and loads .env
|
|
73
|
+
# files (development/test) before anything reads configuration or ENV.
|
|
74
|
+
def setup(root:)
|
|
75
|
+
config.root = root.to_s
|
|
76
|
+
load_env_files!
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Idempotent; real ENV variables are never overwritten.
|
|
81
|
+
def load_env_files!
|
|
82
|
+
files = config.env_files.map { |file| File.expand_path(file, config.root) }
|
|
83
|
+
return if @loaded_env_files == files
|
|
84
|
+
|
|
85
|
+
Dotenv.load(files)
|
|
86
|
+
@loaded_env_files = files
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def logger
|
|
90
|
+
@logger ||= begin
|
|
91
|
+
settings = config.logger
|
|
92
|
+
Logger.new(settings.output, level: settings.level, format: settings.format,
|
|
93
|
+
filter: config.filter_parameters, color: settings.color)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
attr_writer :logger
|
|
98
|
+
|
|
99
|
+
# A 32-byte key for one purpose, derived from secret_key_base, so a key
|
|
100
|
+
# leaked for one use (e.g. storage URLs) can't sign anything else.
|
|
101
|
+
def key_for(purpose)
|
|
102
|
+
secret = config.secret_key_base
|
|
103
|
+
if secret.to_s.empty?
|
|
104
|
+
raise ConfigurationError,
|
|
105
|
+
"SECRET_KEY_BASE is not set (generate one with: openssl rand -hex 64)"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
OpenSSL::HMAC.digest("SHA256", secret, "gemstack:#{purpose}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Forget all process-level state. Intended for tests.
|
|
112
|
+
def reset!
|
|
113
|
+
@config = nil
|
|
114
|
+
@env = nil
|
|
115
|
+
@logger = nil
|
|
116
|
+
@loaded_env_files = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def local_secret
|
|
122
|
+
path = File.join(config.root, "tmp", "#{env}_secret")
|
|
123
|
+
return File.read(path).strip if File.file?(path)
|
|
124
|
+
|
|
125
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
126
|
+
SecureRandom.hex(64).tap { |secret| File.write(path, secret, perm: 0o600) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module GemStack
|
|
8
|
+
module DB
|
|
9
|
+
# Where the connection settings come from, first match wins:
|
|
10
|
+
#
|
|
11
|
+
# 1. config.db.url, set in config/app.rb or config/environments/*.rb
|
|
12
|
+
# 2. DATABASE_URL (TEST_DATABASE_URL in the test environment)
|
|
13
|
+
# 3. config/database.yml — the section for the current environment
|
|
14
|
+
# 4. PostgreSQL on this machine, database <app>_<env> (apps without database.yml)
|
|
15
|
+
#
|
|
16
|
+
# config/database.yml works like Rails' (ERB allowed):
|
|
17
|
+
#
|
|
18
|
+
# default: &default
|
|
19
|
+
# adapter: postgresql # postgresql, mysql2, trilogy or sqlite3
|
|
20
|
+
# pool: <%= ENV.fetch("GEMSTACK_MAX_THREADS", 5) %>
|
|
21
|
+
# development:
|
|
22
|
+
# <<: *default
|
|
23
|
+
# database: shop_development
|
|
24
|
+
# test:
|
|
25
|
+
# <<: *default
|
|
26
|
+
# database: shop_test
|
|
27
|
+
# production:
|
|
28
|
+
# <<: *default
|
|
29
|
+
# url: <%= ENV["DATABASE_URL"] %>
|
|
30
|
+
#
|
|
31
|
+
# The result is a Hash for Sequel.connect: { adapter: "postgres", database:, host:, … }.
|
|
32
|
+
module Configuration
|
|
33
|
+
# Rails-style names (and URL schemes) → Sequel adapters.
|
|
34
|
+
ADAPTERS = {
|
|
35
|
+
"postgresql" => "postgres", "postgres" => "postgres", "postgis" => "postgres",
|
|
36
|
+
"mysql2" => "mysql2", "mysql" => "mysql2", "trilogy" => "trilogy",
|
|
37
|
+
"sqlite3" => "sqlite", "sqlite" => "sqlite"
|
|
38
|
+
}.freeze
|
|
39
|
+
# The gem each adapter needs in the application's Gemfile.
|
|
40
|
+
DRIVER_GEMS = { "postgres" => "pg", "mysql2" => "mysql2", "trilogy" => "trilogy", "sqlite" => "sqlite3" }.freeze
|
|
41
|
+
# database.yml keys with another name in Sequel.
|
|
42
|
+
RENAMED = { "username" => :user, "pool" => :max_connections }.freeze
|
|
43
|
+
# database.yml keys Rails uses that mean nothing to Sequel.
|
|
44
|
+
IGNORED = %w[schema_search_path migrations_paths reaping_frequency idle_timeout checkout_timeout].freeze
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def resolve(config: DB.config, env: GemStack.env, root: GemStack.config.root)
|
|
49
|
+
return from_url(config.url, root: root).merge(source: "config.db.url") if config.url
|
|
50
|
+
|
|
51
|
+
variable = env.test? ? "TEST_DATABASE_URL" : "DATABASE_URL"
|
|
52
|
+
url = ENV.fetch(variable, "").strip
|
|
53
|
+
yaml = yaml_settings(root, env)
|
|
54
|
+
return from_url(url, root: root).merge(pool_from(yaml)).merge(source: variable) unless url.empty?
|
|
55
|
+
return yaml.merge(source: "config/database.yml (#{env})") if yaml
|
|
56
|
+
|
|
57
|
+
name = "#{GemStack.config.name.to_s.tr("-", "_")}_#{env}"
|
|
58
|
+
{ adapter: "postgres", database: name, source: "default (no config/database.yml)" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The section of config/database.yml for this environment, normalized; nil without the file.
|
|
62
|
+
def yaml_settings(root, env)
|
|
63
|
+
path = File.join(root.to_s, "config", "database.yml")
|
|
64
|
+
return nil unless File.file?(path)
|
|
65
|
+
|
|
66
|
+
data = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true, filename: path) || {}
|
|
67
|
+
section = data[env.to_s] or raise ConfigurationError, "config/database.yml has no #{env} section"
|
|
68
|
+
raise ConfigurationError, "config/database.yml: #{env} must be a mapping" unless section.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
from_hash(section, root: root)
|
|
71
|
+
rescue Psych::Exception => e
|
|
72
|
+
raise ConfigurationError, "config/database.yml: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def from_hash(section, root:)
|
|
76
|
+
url = section["url"].to_s.strip
|
|
77
|
+
base = url.empty? ? {} : from_url(url, root: root)
|
|
78
|
+
settings = section.each_with_object({}) do |(key, value), out|
|
|
79
|
+
next if key == "url" || IGNORED.include?(key) || value.nil?
|
|
80
|
+
|
|
81
|
+
out[RENAMED.fetch(key, key.to_sym)] = value
|
|
82
|
+
end
|
|
83
|
+
settings = base.merge(settings) { |_, from_url, from_keys| from_keys || from_url }
|
|
84
|
+
settings[:adapter] = adapter!(settings[:adapter]) if settings[:adapter]
|
|
85
|
+
raise ConfigurationError, "config/database.yml: set adapter (or url)" unless settings[:adapter]
|
|
86
|
+
|
|
87
|
+
settings[:max_connections] = Integer(settings[:max_connections]) if settings[:max_connections]
|
|
88
|
+
settings[:port] = Integer(settings[:port]) if settings[:port]
|
|
89
|
+
sqlite_path(settings, root)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# postgres://user:pass@host:5432/name?sslmode=require, mysql2://…, trilogy://…,
|
|
93
|
+
# sqlite3:db/development.sqlite3 (relative to the app), sqlite3:///abs/path.db, sqlite3::memory:
|
|
94
|
+
def from_url(url, root:)
|
|
95
|
+
scheme = url[/\A([a-z0-9+.-]+):/i, 1].to_s.downcase
|
|
96
|
+
adapter = adapter!(scheme)
|
|
97
|
+
if adapter == "sqlite"
|
|
98
|
+
path = url.sub(/\A[^:]+:/, "").sub(%r{\A//}, "").sub(/\?.*\z/, "")
|
|
99
|
+
return sqlite_path({ adapter: adapter, database: path }, root)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
uri = URI.parse(url)
|
|
103
|
+
settings = { adapter: adapter, host: uri.host, port: uri.port,
|
|
104
|
+
database: decode(uri.path.to_s.delete_prefix("/")), user: decode(uri.user),
|
|
105
|
+
password: decode(uri.password) }
|
|
106
|
+
URI.decode_www_form(uri.query.to_s).each { |key, value| settings[key.to_sym] = value }
|
|
107
|
+
settings.reject { |_, value| value.nil? || value == "" }
|
|
108
|
+
rescue URI::InvalidURIError => e
|
|
109
|
+
raise ConfigurationError, "invalid database URL: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def adapter!(name)
|
|
113
|
+
ADAPTERS.fetch(name.to_s.downcase) do
|
|
114
|
+
raise ConfigurationError, "unsupported database adapter #{name.inspect} " \
|
|
115
|
+
"(use postgresql, mysql2, trilogy or sqlite3)"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def pool_from(yaml) = yaml && yaml[:max_connections] ? { max_connections: yaml[:max_connections] } : {}
|
|
120
|
+
|
|
121
|
+
def sqlite_path(settings, root)
|
|
122
|
+
return settings unless settings[:adapter] == "sqlite"
|
|
123
|
+
|
|
124
|
+
database = settings[:database].to_s
|
|
125
|
+
if database.empty?
|
|
126
|
+
raise ConfigurationError,
|
|
127
|
+
"sqlite3 needs a database file (e.g. database: db/development.sqlite3)"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
settings[:database] = File.expand_path(database, root.to_s) unless database == ":memory:"
|
|
131
|
+
settings
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def decode(value) = value && URI.decode_www_form_component(value)
|
|
135
|
+
|
|
136
|
+
# For logs and `gemstack doctor`: no passwords.
|
|
137
|
+
def describe(settings)
|
|
138
|
+
if settings[:adapter] == "sqlite"
|
|
139
|
+
return "sqlite3 #{settings[:database].to_s.delete_prefix("#{GemStack.config.root}/")}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
host = settings[:host] || "localhost"
|
|
143
|
+
user = settings[:user] ? "#{settings[:user]}@" : ""
|
|
144
|
+
"#{settings[:adapter]}://#{user}#{host}#{":#{settings[:port]}" if settings[:port]}/#{settings[:database]}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module DB
|
|
5
|
+
# Gives Sequel errors their HTTP meaning via GemStack::ErrorMapping (no
|
|
6
|
+
# dependency on the HTTP layer), for PostgreSQL, MySQL and SQLite:
|
|
7
|
+
#
|
|
8
|
+
# record not found → 404 not_found
|
|
9
|
+
# model validation failed → 422 validation_failed + field errors
|
|
10
|
+
# unique / not-null / FK → 422 with the offending column when the database names it
|
|
11
|
+
# row still referenced (FK) → 409 conflict
|
|
12
|
+
# database unreachable → 503 service_unavailable
|
|
13
|
+
module Errors
|
|
14
|
+
KEY_DETAIL = /Key \(([^)]+)\)=/ # PostgreSQL
|
|
15
|
+
MYSQL_KEY = /Duplicate entry .* for key '(?:[^.']+\.)?([^']+)'/ # MySQL 8 names table.index
|
|
16
|
+
SQLITE_COLUMNS = /constraint failed: ((?:[\w.]+(?:, )?)+)/i # UNIQUE / NOT NULL
|
|
17
|
+
FK_COLUMN = /FOREIGN KEY \(`?([^`)]+)`?\)/ # MySQL
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def install!
|
|
22
|
+
# Registered first: the specific classes below take precedence.
|
|
23
|
+
ErrorMapping.register(Sequel::DatabaseError) { |error| mysql_missing_value(error) }
|
|
24
|
+
install_record_errors!
|
|
25
|
+
install_constraint_errors!
|
|
26
|
+
ErrorMapping.register(Sequel::DatabaseConnectionError) { ServiceUnavailable.new("Database unavailable") }
|
|
27
|
+
ErrorMapping.register(Sequel::PoolTimeout) { ServiceUnavailable.new("Database busy, try again") }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def install_record_errors!
|
|
31
|
+
ErrorMapping.register(Sequel::NoMatchingRow) do |error|
|
|
32
|
+
model = error.respond_to?(:dataset) && error.dataset.respond_to?(:model) ? error.dataset.model : nil
|
|
33
|
+
RecordNotFound.new(model&.name ? "#{model.name} not found" : "Record not found")
|
|
34
|
+
end
|
|
35
|
+
ErrorMapping.register(Sequel::ValidationFailed) do |error|
|
|
36
|
+
ValidationError.new(errors: stringify(error.errors))
|
|
37
|
+
end
|
|
38
|
+
ErrorMapping.register(Sequel::InvalidValue) do |error|
|
|
39
|
+
ValidationError.new(error.message.sub(/\A.*?: /, "Invalid value: "), code: "invalid_value")
|
|
40
|
+
end
|
|
41
|
+
ErrorMapping.register(Sequel::MassAssignmentRestriction) do |error|
|
|
42
|
+
BadRequest.new(error.message, code: "unknown_attribute")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def install_constraint_errors!
|
|
47
|
+
ErrorMapping.register(Sequel::UniqueConstraintViolation) do |error|
|
|
48
|
+
field_error(columns(error), "is already taken") || Conflict.new("Record already exists")
|
|
49
|
+
end
|
|
50
|
+
ErrorMapping.register(Sequel::NotNullConstraintViolation) do |error|
|
|
51
|
+
field_error([column(error)].compact, "is required") || ValidationError.new("A required value is missing")
|
|
52
|
+
end
|
|
53
|
+
ErrorMapping.register(Sequel::ForeignKeyConstraintViolation) do |error|
|
|
54
|
+
if referenced_missing?(error)
|
|
55
|
+
field_error(columns(error), "does not exist") || ValidationError.new("A referenced record does not exist")
|
|
56
|
+
else
|
|
57
|
+
Conflict.new("Record is still referenced by other records", code: "still_referenced")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
ErrorMapping.register(Sequel::CheckConstraintViolation) do
|
|
61
|
+
ValidationError.new("A value violates a database constraint", code: "constraint_violation")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# MySQL (strict mode) reports an omitted NOT NULL column without a
|
|
66
|
+
# default as a plain error: "Field 'name' doesn't have a default value".
|
|
67
|
+
def mysql_missing_value(error)
|
|
68
|
+
name = error.message[/Field '([^']+)' doesn't have a default value/, 1]
|
|
69
|
+
name && ValidationError.new(errors: { name => ["is required"] })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stringify(errors) = errors.to_h { |key, messages| [Array(key).join(","), Array(messages)] }
|
|
73
|
+
|
|
74
|
+
def field_error(names, message)
|
|
75
|
+
return nil if names.empty?
|
|
76
|
+
|
|
77
|
+
ValidationError.new(errors: names.to_h { |name| [name, [message]] })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def pg_error(error) = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil
|
|
81
|
+
|
|
82
|
+
# PostgreSQL: "is not present in table"; MySQL: "Cannot add or update a child row".
|
|
83
|
+
# SQLite doesn't say which side failed, so it is reported as a conflict.
|
|
84
|
+
def referenced_missing?(error)
|
|
85
|
+
message = error.message
|
|
86
|
+
message.include?("is not present") || message.include?("Cannot add or update a child row")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The columns named by a unique or foreign-key violation, when the database says.
|
|
90
|
+
def columns(error)
|
|
91
|
+
message = error.message
|
|
92
|
+
if (detail = pg_detail(error) || message[KEY_DETAIL]) && (match = KEY_DETAIL.match(detail))
|
|
93
|
+
match[1].split(",").map(&:strip).map { |c| c.delete('"') }
|
|
94
|
+
elsif (match = SQLITE_COLUMNS.match(message))
|
|
95
|
+
match[1].split(", ").map { |name| name.split(".").last }
|
|
96
|
+
elsif (match = FK_COLUMN.match(message))
|
|
97
|
+
[match[1]]
|
|
98
|
+
elsif (match = MYSQL_KEY.match(message))
|
|
99
|
+
index_column(match[1])
|
|
100
|
+
else
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
rescue StandardError
|
|
104
|
+
[]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def column(error)
|
|
108
|
+
message = error.message
|
|
109
|
+
(defined?(PG::PG_DIAG_COLUMN_NAME) && pg_error(error)&.result&.error_field(PG::PG_DIAG_COLUMN_NAME)) ||
|
|
110
|
+
message[/column "([^"]+)"/, 1] || message[/Column '([^']+)' cannot be null/, 1] ||
|
|
111
|
+
sqlite_column(message)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def sqlite_column(message)
|
|
117
|
+
list = message[SQLITE_COLUMNS, 1] or return nil
|
|
118
|
+
list.split(", ").first.split(".").last
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def pg_detail(error)
|
|
122
|
+
return nil unless defined?(PG::PG_DIAG_MESSAGE_DETAIL)
|
|
123
|
+
|
|
124
|
+
pg_error(error)&.result&.error_field(PG::PG_DIAG_MESSAGE_DETAIL)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# MySQL reports the index, not the column. `unique: true` on a column
|
|
128
|
+
# (what the generators write) names the index after the column; other
|
|
129
|
+
# indexes (table_column_index, composite) can't be mapped to one field.
|
|
130
|
+
def index_column(name)
|
|
131
|
+
name == "PRIMARY" || name.match?(/_(?:key|unique|uniq|index)\z/) ? [] : [name]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
GemStack::DB::Errors.install!
|