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,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"
|