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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+ require "erubi"
5
+ require "uri"
6
+ require "gemstack/core"
7
+
8
+ module GemStack
9
+ # Email.
10
+ #
11
+ # class AccountMailer < GemStack::Mailer
12
+ # default from: "Shop <hello@shop.test>"
13
+ #
14
+ # def welcome(user_id)
15
+ # @user = User.find(user_id)
16
+ # mail to: @user.email, subject: "Welcome!" # templates/account_mailer/welcome.{text,html}.erb
17
+ # end
18
+ # end
19
+ #
20
+ # AccountMailer.welcome(user.id).deliver_later # through GemStack::Jobs (pass ids)
21
+ # AccountMailer.welcome(user.id).deliver_now
22
+ module Mail
23
+ class Config < Settings
24
+ # :smtp (production), :log (development: logged and saved to tmp/mail),
25
+ # :test (captured in GemStack::Mail.deliveries), or an object with #deliver(message).
26
+ setting :delivery, default: lambda {
27
+ if GemStack.env.test? then :test
28
+ elsif GemStack.env.development? then :log
29
+ else :smtp
30
+ end
31
+ }
32
+ # smtp://user:password@smtp.example.com:587 (STARTTLS) or smtps://…:465
33
+ setting :smtp_url, default: -> { ENV.fetch("SMTP_URL", nil) }
34
+ setting :default_from, default: -> { ENV.fetch("MAIL_FROM", "#{GemStack.config.name} <no-reply@localhost>") }
35
+ setting :templates_path, default: "app/mailers/templates"
36
+ setting :preview_dir, default: "tmp/mail"
37
+ # Queue used by deliver_later.
38
+ setting :queue, default: "mailers"
39
+ end
40
+
41
+ class DeliveryError < Error; end
42
+
43
+ # Loaded on first use, also by a jobs worker resolving the queued class name.
44
+ autoload :DeliveryJob, "gemstack/mail/delivery_job"
45
+
46
+ @deliveries = []
47
+ @mutex = Mutex.new
48
+
49
+ class << self
50
+ def config = GemStack.config.mail
51
+
52
+ # Messages delivered with the :test method.
53
+ attr_reader :deliveries
54
+
55
+ def deliver(message)
56
+ method = config.delivery
57
+ case method
58
+ when :test, "test" then @mutex.synchronize { @deliveries << message }
59
+ when :log, "log" then deliver_to_log(message)
60
+ when :smtp, "smtp" then deliver_smtp(message)
61
+ else
62
+ unless method.respond_to?(:deliver)
63
+ raise ConfigurationError,
64
+ "a mail delivery method must respond to #deliver"
65
+ end
66
+
67
+ method.deliver(message)
68
+ end
69
+ GemStack.logger.info("mail.delivered", to: Array(message.to).join(","), subject: message.subject,
70
+ via: method.is_a?(Symbol) ? method : method.class.name)
71
+ message
72
+ end
73
+
74
+ def smtp_settings(url = config.smtp_url)
75
+ if url.to_s.empty?
76
+ raise ConfigurationError,
77
+ "set SMTP_URL (e.g. smtp://user:pass@smtp.example.com:587) to send mail"
78
+ end
79
+
80
+ uri = URI.parse(url)
81
+ {
82
+ address: uri.host, port: uri.port || (uri.scheme == "smtps" ? 465 : 587),
83
+ user_name: uri.user && URI.decode_www_form_component(uri.user),
84
+ password: uri.password && URI.decode_www_form_component(uri.password),
85
+ authentication: uri.user ? :plain : nil, tls: uri.scheme == "smtps",
86
+ enable_starttls_auto: uri.scheme != "smtps", open_timeout: 5, read_timeout: 10
87
+ }.compact
88
+ end
89
+
90
+ private
91
+
92
+ def deliver_smtp(message)
93
+ message.delivery_method(:smtp, smtp_settings)
94
+ message.deliver
95
+ rescue Net::SMTPError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
96
+ raise DeliveryError, "SMTP delivery failed: #{e.class}: #{e.message}"
97
+ end
98
+
99
+ # Development: a log line plus a copy on disk (.eml, and .html to open in a browser).
100
+ def deliver_to_log(message)
101
+ dir = File.expand_path(config.preview_dir, GemStack.config.root)
102
+ FileUtils.mkdir_p(dir)
103
+ slug = message.subject.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-")[0, 40]
104
+ base = File.join(dir, "#{Time.now.strftime("%Y%m%d-%H%M%S-%L")}-#{slug}")
105
+ File.write("#{base}.eml", message.to_s)
106
+ html = message.html_part&.decoded
107
+ File.write("#{base}.html", html) if html
108
+ GemStack.logger.info("mail saved", to: Array(message.to).join(","), subject: message.subject,
109
+ file: "#{base}#{".html" if html}")
110
+ end
111
+ end
112
+ end
113
+
114
+ # Base class for mailers. Public instance methods are mail "actions";
115
+ # calling one on the class returns a Delivery. An action that returns
116
+ # without calling `mail` sends nothing (e.g. the user was deleted since).
117
+ class Mailer
118
+ # Rendered as the value of @variables set in the action.
119
+ class TemplateContext
120
+ def initialize(mailer)
121
+ mailer.instance_variables.each { |ivar| instance_variable_set(ivar, mailer.instance_variable_get(ivar)) }
122
+ end
123
+
124
+ def render(source, escape:) = instance_eval(Erubi::Engine.new(source, escape: escape).src)
125
+ end
126
+
127
+ # A message waiting to be delivered.
128
+ class Delivery
129
+ attr_reader :mailer_class, :action, :args
130
+
131
+ def initialize(mailer_class, action, args)
132
+ @mailer_class = mailer_class
133
+ @action = action
134
+ @args = args
135
+ end
136
+
137
+ def message
138
+ return @message if defined?(@message)
139
+
140
+ @message = mailer_class.new.build(action, args)
141
+ end
142
+
143
+ # nil when the action decided not to send (it returned without calling mail).
144
+ def deliver_now
145
+ msg = message
146
+ msg && Mail.deliver(msg)
147
+ end
148
+
149
+ # Delivers from a background job (arguments must be JSON values — pass ids).
150
+ def deliver_later(wait: nil)
151
+ require "gemstack/jobs" # part of the gemstack gem; loaded here if config/app.rb doesn't
152
+ job = wait ? Mail::DeliveryJob.set(wait: wait) : Mail::DeliveryJob
153
+ job.perform_later(mailer_class.name, action.to_s, args)
154
+ end
155
+ end
156
+
157
+ class << self
158
+ def default(**headers) = defaults.merge!(headers.transform_keys(&:to_sym))
159
+ def defaults = @defaults ||= superclass.respond_to?(:defaults) ? superclass.defaults.dup : {}
160
+
161
+ def actions
162
+ (public_instance_methods(true) - Mailer.public_instance_methods(true)).map(&:to_s)
163
+ end
164
+
165
+ def respond_to_missing?(name, include_private = false) = actions.include?(name.to_s) || super
166
+
167
+ def method_missing(name, *args)
168
+ return super unless actions.include?(name.to_s)
169
+
170
+ Delivery.new(self, name.to_s, args)
171
+ end
172
+ end
173
+
174
+ def build(action, args)
175
+ @action = action
176
+ public_send(action, *args)
177
+ GemStack.logger.debug("mail skipped", mailer: self.class.name, action: action) unless @message
178
+ @message
179
+ end
180
+
181
+ # Builds the message. Bodies come from text:/html: or, when omitted, from
182
+ # templates: <templates_path>/<mailer>/<action>.text.erb and .html.erb
183
+ # (HTML templates escape <%= %> output; use <%== %> for trusted markup).
184
+ def mail(to:, subject:, text: nil, html: nil, from: nil, cc: nil, bcc: nil, reply_to: nil, template: @action)
185
+ headers = self.class.defaults
186
+ text ||= render_template(template, "text", escape: false)
187
+ html ||= render_template(template, "html", escape: true)
188
+ raise Error, "#{self.class.name}##{@action}: no body (give text:/html: or add a template)" unless text || html
189
+
190
+ @message = build_message(to: to, subject: subject, text: text, html: html,
191
+ from: from || headers[:from] || Mail.config.default_from, cc: cc, bcc: bcc,
192
+ reply_to: reply_to || headers[:reply_to])
193
+ end
194
+
195
+ private
196
+
197
+ def render_template(name, format, escape:)
198
+ path = File.join(GemStack.config.root, Mail.config.templates_path, Inflector.underscore(self.class.name),
199
+ "#{name}.#{format}.erb")
200
+ return nil unless File.file?(path)
201
+
202
+ TemplateContext.new(self).render(File.read(path), escape: escape)
203
+ end
204
+
205
+ def build_message(to:, subject:, text:, html:, from:, cc:, bcc:, reply_to:)
206
+ message = ::Mail.new
207
+ message.from = from
208
+ message.to = to
209
+ message.cc = cc if cc
210
+ message.bcc = bcc if bcc
211
+ message.reply_to = reply_to if reply_to
212
+ message.subject = subject
213
+ if text
214
+ message.text_part = ::Mail::Part.new do
215
+ body text
216
+ content_type "text/plain; charset=UTF-8"
217
+ end
218
+ end
219
+ if html
220
+ message.html_part = ::Mail::Part.new do
221
+ body html
222
+ content_type "text/html; charset=UTF-8"
223
+ end
224
+ end
225
+ message
226
+ end
227
+ end
228
+ end
229
+
230
+ GemStack::Config.namespace(:mail, GemStack::Mail::Config)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # Registry through which optional modules hook into application boot
5
+ # without the core (or the umbrella gem) knowing about them in advance.
6
+ #
7
+ # # inside a module such as gemstack/jobs:
8
+ # GemStack::Plugins.register(:jobs) do |app|
9
+ # app.config.http.middleware.use GemStack::Jobs::Middleware
10
+ # app.on_shutdown { GemStack::Jobs.stop }
11
+ # end
12
+ #
13
+ # Hooks run once per boot, after configuration files are loaded and before
14
+ # the application is built, in registration order.
15
+ module Plugins
16
+ Plugin = Struct.new(:name, :hook)
17
+
18
+ @registry = {}
19
+ @mutex = Mutex.new
20
+
21
+ class << self
22
+ def register(name, &hook)
23
+ raise ArgumentError, "plugin #{name.inspect} needs a block" unless hook
24
+
25
+ @mutex.synchronize { @registry[name.to_sym] = Plugin.new(name.to_sym, hook) }
26
+ end
27
+
28
+ def unregister(name) = @mutex.synchronize { @registry.delete(name.to_sym) }
29
+ def registered?(name) = @registry.key?(name.to_sym)
30
+ def names = @registry.keys
31
+ def each(&) = @registry.values.each(&)
32
+
33
+ def run(app)
34
+ each { |plugin| plugin.hook.call(app) }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/core"
4
+ require_relative "types"
5
+
6
+ module GemStack
7
+ # Declarative input validation and coercion.
8
+ #
9
+ # class ProductInput < GemStack::Schema
10
+ # required :name, :string, max_length: 120
11
+ # required :price, :decimal, gt: 0
12
+ # optional :active, :boolean, default: true
13
+ # optional :tags, [:string]
14
+ # optional :dimensions do
15
+ # required :width, :integer
16
+ # end
17
+ # end
18
+ #
19
+ # ProductInput.call(params) # => { name: "Lamp", price: 0.999e1, active: true }
20
+ # # or raises GemStack::ValidationError (422) with
21
+ # # errors: { "price" => ["must be greater than 0"] }
22
+ #
23
+ # - Output has symbol keys and only declared fields (an allow-list).
24
+ # - Values are coerced to their type ("12" → 12 for :integer).
25
+ # - For non-string types an empty string counts as "not given" (HTML forms).
26
+ # - A required string that is blank is reported as "is required".
27
+ # - Error keys are paths: "dimensions.width", "tags.2".
28
+ class Schema
29
+ Field = Struct.new(:name, :type, :required, :nullable, :default, :rules, :schema, :array, keyword_init: true) do
30
+ def default? = !default.equal?(NO_DEFAULT)
31
+ def default_value = default.respond_to?(:call) ? default.call : default
32
+ def ts_optional? = !required && !default?
33
+ end
34
+
35
+ NO_DEFAULT = Object.new.freeze
36
+ RULES = %i[gt gte lt lte min_length max_length in format].freeze
37
+ STRING_TYPES = %i[string text].freeze
38
+
39
+ # rule => [passes?(value, arg), message(arg)]
40
+ RULE_CHECKS = {
41
+ gt: [->(v, a) { v > a }, ->(a) { "must be greater than #{a}" }],
42
+ gte: [->(v, a) { v >= a }, ->(a) { "must be greater than or equal to #{a}" }],
43
+ lt: [->(v, a) { v < a }, ->(a) { "must be less than #{a}" }],
44
+ lte: [->(v, a) { v <= a }, ->(a) { "must be less than or equal to #{a}" }],
45
+ min_length: [->(v, a) { v.to_s.length >= a }, ->(a) { "is too short (minimum #{a} characters)" }],
46
+ max_length: [->(v, a) { v.to_s.length <= a }, ->(a) { "is too long (maximum #{a} characters)" }],
47
+ in: [->(v, a) { a.include?(v) }, ->(a) { "must be one of: #{a.to_a.join(", ")}" }],
48
+ format: [->(v, a) { a.match?(v.to_s) }, ->(_) { "is invalid" }]
49
+ }.freeze
50
+
51
+ class << self
52
+ def fields
53
+ @fields ||= superclass.respond_to?(:fields) ? superclass.fields.dup : {}
54
+ end
55
+
56
+ # Name used for this schema's TypeScript/OpenAPI type.
57
+ attr_writer :type_name
58
+
59
+ def type_name
60
+ @type_name || name&.split("::")&.join
61
+ end
62
+
63
+ def required(name, type = nil, **options, &) = add_field(name, type, true, options, &)
64
+ def optional(name, type = nil, **options, &) = add_field(name, type, false, options, &)
65
+
66
+ # An anonymous schema from a block: Schema.define { required :q, :string }
67
+ def define(type_name = nil, &)
68
+ Class.new(self) do
69
+ self.type_name = type_name
70
+ class_exec(&)
71
+ end
72
+ end
73
+
74
+ # Every field optional and without defaults — for PATCH-style updates.
75
+ def partial(type_name = nil)
76
+ source = self
77
+ Class.new(Schema) do
78
+ self.type_name = type_name
79
+ source.fields.each_value do |field|
80
+ fields[field.name] = field.dup.tap do |copy|
81
+ copy.required = false
82
+ copy.default = NO_DEFAULT
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ # A schema derived from a model's field declarations (anything that
89
+ # responds to #gemstack_fields → { name => field with #type and #options }).
90
+ # Fields that are NOT NULL without a default become required.
91
+ def from_model(model, only: nil, except: nil, type_name: nil)
92
+ declared = model.gemstack_fields
93
+ names = only ? Array(only).map(&:to_sym) : declared.keys
94
+ names -= Array(except).map(&:to_sym)
95
+ define(type_name) do
96
+ names.each do |name|
97
+ field = declared.fetch(name) { raise ArgumentError, "#{model} has no field #{name.inspect}" }
98
+ opts = field.options
99
+ rules = opts.slice(*RULES)
100
+ rules[:max_length] ||= opts[:size] if opts[:size].is_a?(Integer)
101
+ required = opts[:null] == false && !opts.key?(:default) && field.type != :boolean
102
+ add_field(name, field.type, required, rules.merge(nullable: opts[:null] != false))
103
+ end
104
+ end
105
+ end
106
+
107
+ def call(input)
108
+ errors = {}
109
+ result = coerce_object(input, nil, errors)
110
+ raise ValidationError.new(errors: errors) unless errors.empty?
111
+
112
+ result
113
+ end
114
+
115
+ # Like #call but returns [result, errors] instead of raising.
116
+ def validate(input)
117
+ errors = {}
118
+ result = coerce_object(input, nil, errors)
119
+ [errors.empty? ? result : nil, errors]
120
+ end
121
+
122
+ def coerce_object(input, path, errors)
123
+ input = normalize_input(input)
124
+ unless input.is_a?(Hash)
125
+ errors[path || "base"] = ["must be an object"]
126
+ return nil
127
+ end
128
+
129
+ fields.each_value.with_object({}) do |field, output|
130
+ coerce_field(field, input, path, output, errors)
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def add_field(name, type, required, options, &block)
137
+ options = options.dup
138
+ nullable = options.delete(:nullable) || false
139
+ default = options.key?(:default) ? options.delete(:default) : NO_DEFAULT
140
+ unknown = options.keys - RULES
141
+ raise ArgumentError, "unknown option(s) #{unknown.inspect} for #{name}" unless unknown.empty?
142
+
143
+ array = false
144
+ schema = nil
145
+ if type.is_a?(Array)
146
+ array = true
147
+ type = type.first
148
+ elsif type == :array
149
+ array = true
150
+ type = nil
151
+ end
152
+ if block
153
+ schema = Schema.define(&block)
154
+ type = nil
155
+ elsif type.is_a?(Class) && type <= Schema
156
+ schema = type
157
+ type = nil
158
+ elsif type.nil?
159
+ raise ArgumentError, "#{name}: give a type (e.g. :string) or a block"
160
+ else
161
+ Types.fetch(type) # validate early
162
+ type = Types::CLASS_ALIASES.fetch(type, type).to_sym
163
+ end
164
+
165
+ fields[name.to_sym] = Field.new(name: name.to_sym, type: type, required: required, nullable: nullable,
166
+ default: default, rules: options.freeze, schema: schema, array: array)
167
+ end
168
+
169
+ # absent → default or omitted; required + absent/nil/blank → "is required";
170
+ # explicit null → nil when nullable, otherwise "can't be null".
171
+ def coerce_field(field, input, path, output, errors)
172
+ key = field.name.to_s
173
+ field_path = path ? "#{path}.#{key}" : key
174
+ value = input[key]
175
+ present = input.key?(key) && !empty_form_value?(field, value)
176
+
177
+ if field.required && (!present || value.nil? || blank_string?(value))
178
+ missing_required(field, present, field_path, output, errors)
179
+ elsif !present
180
+ output[field.name] = field.default_value if field.default?
181
+ else
182
+ coerce_present(field, value, field_path, output, errors)
183
+ end
184
+ end
185
+
186
+ def coerce_present(field, value, path, output, errors)
187
+ if value.nil?
188
+ field.nullable ? output[field.name] = nil : (errors[path] ||= []) << "can't be null"
189
+ else
190
+ coerced = coerce_value(field, value, path, errors)
191
+ output[field.name] = coerced unless coerced.equal?(NO_DEFAULT)
192
+ end
193
+ end
194
+
195
+ def missing_required(field, present, path, output, errors)
196
+ return output[field.name] = field.default_value if field.default? && !present
197
+
198
+ (errors[path] ||= []) << "is required"
199
+ end
200
+
201
+ def coerce_value(field, value, path, errors)
202
+ return coerce_array(field, value, path, errors) if field.array
203
+
204
+ coerce_single(field, value, path, errors)
205
+ end
206
+
207
+ def coerce_array(field, value, path, errors)
208
+ value = value.values if value.is_a?(Hash) && value.keys.all? { |k| k.to_s.match?(/\A\d+\z/) } # form arrays
209
+ unless value.is_a?(Array)
210
+ (errors[path] ||= []) << "must be a list"
211
+ return NO_DEFAULT
212
+ end
213
+
214
+ value.each_with_index.map { |item, index| coerce_single(field, item, "#{path}.#{index}", errors) }
215
+ end
216
+
217
+ def coerce_single(field, value, path, errors)
218
+ return field.schema.coerce_object(value, path, errors) if field.schema
219
+
220
+ coerced = Types.fetch(field.type).coerce(value)
221
+ check_rules(field.rules, coerced, path, errors)
222
+ coerced
223
+ rescue Types::CoercionError => e
224
+ (errors[path] ||= []) << e.message
225
+ NO_DEFAULT
226
+ end
227
+
228
+ def check_rules(rules, value, path, errors)
229
+ messages = rules.filter_map { |rule, arg| rule_message(rule, arg, value) }
230
+ (errors[path] ||= []).concat(messages) unless messages.empty?
231
+ end
232
+
233
+ def rule_message(rule, arg, value)
234
+ check, message = RULE_CHECKS.fetch(rule)
235
+ check.call(value, arg) ? nil : message.call(arg)
236
+ end
237
+
238
+ def normalize_input(input)
239
+ input = input.to_unsafe_h if input.respond_to?(:to_unsafe_h) # GemStack::Params
240
+ input.is_a?(Hash) ? input.transform_keys(&:to_s) : input
241
+ end
242
+
243
+ def blank_string?(value) = value.is_a?(String) && value.strip.empty?
244
+
245
+ # An empty form input for a non-string field means "not given".
246
+ def empty_form_value?(field, value) = value == "" && !STRING_TYPES.include?(field.type)
247
+ end
248
+ end
249
+ end
250
+
251
+ require_relative "serializer"