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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. 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!