monkrb 0.15.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 +7 -0
- data/CHANGELOG.md +581 -0
- data/LICENSE.txt +21 -0
- data/README.md +88 -0
- data/exe/monk +116 -0
- data/lib/monk/assets.rb +197 -0
- data/lib/monk/auth/errors.rb +13 -0
- data/lib/monk/auth/helpers.rb +76 -0
- data/lib/monk/auth/login_token.rb +11 -0
- data/lib/monk/auth/rate_limiter.rb +46 -0
- data/lib/monk/auth/session.rb +11 -0
- data/lib/monk/auth.rb +301 -0
- data/lib/monk/base.rb +323 -0
- data/lib/monk/context.rb +78 -0
- data/lib/monk/environment.rb +50 -0
- data/lib/monk/errors.rb +37 -0
- data/lib/monk/freeze_hooks.rb +23 -0
- data/lib/monk/live/client/idiomorph.LICENSE +13 -0
- data/lib/monk/live/client/idiomorph.js +4 -0
- data/lib/monk/live/client/monk_live.js +204 -0
- data/lib/monk/live/client/protocol.js +87 -0
- data/lib/monk/live/envelope.rb +51 -0
- data/lib/monk/live/errors.rb +9 -0
- data/lib/monk/live/helpers.rb +22 -0
- data/lib/monk/live/policy.rb +58 -0
- data/lib/monk/live/publisher.rb +91 -0
- data/lib/monk/live/renderer.rb +47 -0
- data/lib/monk/live/session.rb +121 -0
- data/lib/monk/live.rb +96 -0
- data/lib/monk/log.rb +130 -0
- data/lib/monk/persistence/errors.rb +7 -0
- data/lib/monk/persistence/model.rb +41 -0
- data/lib/monk/persistence/pg/errors.rb +4 -0
- data/lib/monk/persistence/pg/migrator.rb +165 -0
- data/lib/monk/persistence/pg/model.rb +233 -0
- data/lib/monk/persistence/pg.rb +34 -0
- data/lib/monk/persistence.rb +113 -0
- data/lib/monk/scaffold.rb +606 -0
- data/lib/monk/settings.rb +151 -0
- data/lib/monk/state_ractor.rb +45 -0
- data/lib/monk/templates/auth/config/auth.rb +28 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
- data/lib/monk/templates/base/.dockerignore +5 -0
- data/lib/monk/templates/base/.gitignore +4 -0
- data/lib/monk/templates/base/.ruby-version +1 -0
- data/lib/monk/templates/base/Dockerfile +28 -0
- data/lib/monk/templates/base/Gemfile +7 -0
- data/lib/monk/templates/base/bin/server +5 -0
- data/lib/monk/templates/base/bin/websocket_server +62 -0
- data/lib/monk/templates/base/config/settings.rb +30 -0
- data/lib/monk/templates/base/config.ru +13 -0
- data/lib/monk/templates/base/public/css/app.css +17 -0
- data/lib/monk/templates/base/public/js/app.js +5 -0
- data/lib/monk/templates/base/views/index.erb +6 -0
- data/lib/monk/templates/base/views/layouts/app.erb +18 -0
- data/lib/monk/templates/live/bin/websocket_server +30 -0
- data/lib/monk/templates/live/config/live.rb +47 -0
- data/lib/monk/templates/live/config.ru +27 -0
- data/lib/monk/templates/live/views/index.erb +18 -0
- data/lib/monk/templates/live/views/live/_hits.erb +1 -0
- data/lib/monk/templates/postgres/Dockerfile +30 -0
- data/lib/monk/templates/postgres/Gemfile.extra +2 -0
- data/lib/monk/templates/postgres/bin/console +7 -0
- data/lib/monk/templates/postgres/bin/migrate +22 -0
- data/lib/monk/templates/postgres/bin/setup_db +9 -0
- data/lib/monk/templates/postgres/config/persistence.rb +10 -0
- data/lib/monk/templates/redis/Gemfile.extra +1 -0
- data/lib/monk/version.rb +9 -0
- data/lib/monk/views.rb +175 -0
- data/lib/monk/websocket/connection.rb +226 -0
- data/lib/monk/websocket/errors.rb +9 -0
- data/lib/monk/websocket/frame.rb +71 -0
- data/lib/monk/websocket/handshake.rb +77 -0
- data/lib/monk/websocket/redis_fanout.rb +103 -0
- data/lib/monk/websocket/registry.rb +92 -0
- data/lib/monk/websocket/server.rb +234 -0
- data/lib/monk/websocket.rb +19 -0
- data/lib/monk.rb +45 -0
- metadata +252 -0
data/lib/monk/auth.rb
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
require_relative "freeze_hooks"
|
|
6
|
+
require_relative "auth/errors"
|
|
7
|
+
require_relative "auth/login_token"
|
|
8
|
+
require_relative "auth/session"
|
|
9
|
+
require_relative "auth/helpers"
|
|
10
|
+
require_relative "auth/rate_limiter"
|
|
11
|
+
|
|
12
|
+
module Monk
|
|
13
|
+
# Passwordless token auth. Opt-in: require "monk/auth" explicitly --
|
|
14
|
+
# `require "monk"` alone does not load this, since it depends on a
|
|
15
|
+
# persistence backend the app may not use (docs/design/auth-sessions.md).
|
|
16
|
+
module Auth
|
|
17
|
+
REQUIRED_CONFIG_KEYS = %i[db_name secret login_ttl session_ttl].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Called from Base#freeze! (Seam B), via Monk.freeze_hooks. Freezes
|
|
21
|
+
# the value, not the module -- Monk::Auth is always Ractor.shareable?
|
|
22
|
+
# regardless of its ivars, so freezing the module itself would do
|
|
23
|
+
# nothing (docs/design/persistence-ractor-connections.md "Phase 4/5 finding").
|
|
24
|
+
def freeze_registry!
|
|
25
|
+
begin
|
|
26
|
+
@config = Ractor.make_shareable(@config)
|
|
27
|
+
rescue ArgumentError, Ractor::IsolationError => e
|
|
28
|
+
raise Monk::UnshareableBlockError,
|
|
29
|
+
"Monk::Auth.configure(deliver:) is not Ractor-shareable: #{e.message} " \
|
|
30
|
+
"(build it where self is shareable, e.g. as a module constant at class/module-body " \
|
|
31
|
+
"scope -- self at a script's top level, e.g. config/auth.rb itself, is not shareable, " \
|
|
32
|
+
"same constraint as Monk::Live.authorize blocks)"
|
|
33
|
+
end
|
|
34
|
+
freeze_rqrcode!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configure(
|
|
38
|
+
db_name: nil, secret: nil, login_ttl: nil, session_ttl: nil, redirect_allowlist: [], secure: true,
|
|
39
|
+
deliver: nil
|
|
40
|
+
)
|
|
41
|
+
config = {
|
|
42
|
+
db_name: db_name, secret: secret, login_ttl: login_ttl, session_ttl: session_ttl,
|
|
43
|
+
redirect_allowlist: redirect_allowlist, secure: secure, deliver: deliver,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
REQUIRED_CONFIG_KEYS.each do |key|
|
|
47
|
+
raise Monk::MissingAuthConfigError,
|
|
48
|
+
"Monk::Auth.configure is missing required key #{key.inspect}" if config[key].nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
LoginToken.db_name = db_name
|
|
52
|
+
Session.db_name = db_name
|
|
53
|
+
|
|
54
|
+
@config = config
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def config
|
|
58
|
+
@config
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Test-only: drops the current config. Not part of the app-facing API.
|
|
62
|
+
def reset!
|
|
63
|
+
@config = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def request_login(email, redirect_to: nil)
|
|
67
|
+
ensure_configured!
|
|
68
|
+
if redirect_to && !config[:redirect_allowlist].include?(redirect_to)
|
|
69
|
+
raise Monk::InvalidRedirectError, "#{redirect_to.inspect} is not in Monk::Auth's redirect_allowlist"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
raw = SecureRandom.urlsafe_base64(32)
|
|
73
|
+
|
|
74
|
+
LoginToken.create(
|
|
75
|
+
email: email,
|
|
76
|
+
token_hash: hash_token(raw),
|
|
77
|
+
redirect_to: redirect_to,
|
|
78
|
+
expires_at: Time.now + config[:login_ttl],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
raw
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The single place an app's login-request handler calls to get a
|
|
85
|
+
# magic link to its owner: uses the `deliver:` callable passed to
|
|
86
|
+
# `configure` (e.g. wrapping a mailer or a provider's HTTP API) when
|
|
87
|
+
# one is set, regardless of environment -- an app that wants to
|
|
88
|
+
# exercise real delivery in development is free to. With no
|
|
89
|
+
# `deliver:` configured, falls back to #log_dev_link in development
|
|
90
|
+
# (see below), and raises everywhere else: a silent no-op here would
|
|
91
|
+
# mean an app that forgot to wire delivery finds out only when a
|
|
92
|
+
# user reports never receiving their link, in production, which is
|
|
93
|
+
# the wrong place to fail (docs/design/auth-sessions.md's "Email
|
|
94
|
+
# delivery stays outside the framework" -- Monk still needs to know
|
|
95
|
+
# it was told to send *something*).
|
|
96
|
+
#
|
|
97
|
+
# `link` is the app's own job to build (Monk doesn't own routing, so
|
|
98
|
+
# it can't know the callback path) -- from a trusted origin, e.g.
|
|
99
|
+
# Monk::Settings[:public_url], never from request headers like
|
|
100
|
+
# X-Forwarded-Proto/Host, which any direct client can spoof.
|
|
101
|
+
# `token` is passed through for a deliver: that wants it (an SMS
|
|
102
|
+
# body instead of a link, say); most won't need it.
|
|
103
|
+
def deliver_link(email:, link:, token: nil)
|
|
104
|
+
ensure_configured!
|
|
105
|
+
|
|
106
|
+
if config[:deliver]
|
|
107
|
+
config[:deliver].call(email: email, link: link, token: token)
|
|
108
|
+
elsif Monk.env.development?
|
|
109
|
+
log_dev_link(link, subject: email)
|
|
110
|
+
else
|
|
111
|
+
raise MissingAuthDeliveryError,
|
|
112
|
+
"Monk::Auth.configure(deliver:) is not set, and this isn't development -- " \
|
|
113
|
+
"#{email.inspect}'s magic link would never be sent. Configure a deliver: " \
|
|
114
|
+
"callable (wrapping your mailer or provider's API), or call " \
|
|
115
|
+
"Monk::Auth.log_dev_link directly if that's truly intended here."
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Convenience for an app's own login-request handler: prints a
|
|
120
|
+
# magic link to the dev console (and log/development.log via
|
|
121
|
+
# Monk::Log.info), plus a scannable QR code beneath it if the
|
|
122
|
+
# optional `rqrcode` gem is in the app's own Gemfile -- makes
|
|
123
|
+
# testing the login flow from a second device (another browser, a
|
|
124
|
+
# phone) trivial without a mailer. A no-op outside development,
|
|
125
|
+
# same posture as every other dev-only escape hatch in this
|
|
126
|
+
# framework (docs/history/secure-cookie-dev-http.md). #deliver_link
|
|
127
|
+
# above is what an app should call day to day; this is its
|
|
128
|
+
# development-only fallback and is still fine to call directly.
|
|
129
|
+
#
|
|
130
|
+
# subject: is optional context for the printed line only (e.g. the
|
|
131
|
+
# email being logged in) -- useful once more than one login is in
|
|
132
|
+
# flight at a time (two test users, two devices), never persisted
|
|
133
|
+
# or otherwise part of the token itself.
|
|
134
|
+
#
|
|
135
|
+
# rqrcode is opt-in, same as pg/redis: not a runtime dependency of
|
|
136
|
+
# monk itself (see monk.gemspec) -- an app that wants the QR code
|
|
137
|
+
# declares it in its own Gemfile. Without it, this still logs the
|
|
138
|
+
# plain link, just no QR beneath it.
|
|
139
|
+
def log_dev_link(link, subject: nil)
|
|
140
|
+
return unless Monk.env.development?
|
|
141
|
+
|
|
142
|
+
line = subject ? "[dev] magic link for #{subject}: #{link}" : "[dev] magic link: #{link}"
|
|
143
|
+
$stdout.puts(line)
|
|
144
|
+
$stdout.flush
|
|
145
|
+
Monk::Log.info(line)
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
require "rqrcode"
|
|
149
|
+
rescue LoadError
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
$stdout.puts(compact_qr(RQRCode::QRCode.new(link, level: :l)))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def redeem(raw)
|
|
157
|
+
ensure_configured!
|
|
158
|
+
return nil if raw.nil? || raw.empty?
|
|
159
|
+
|
|
160
|
+
row = LoginToken.where(token_hash: hash_token(raw)).first
|
|
161
|
+
return nil unless row
|
|
162
|
+
return nil if row[:expires_at] <= Time.now
|
|
163
|
+
|
|
164
|
+
claimed = LoginToken.claim({ id: row[:id], used_at: nil }, used_at: Time.now)
|
|
165
|
+
return nil unless claimed
|
|
166
|
+
|
|
167
|
+
session_raw = SecureRandom.urlsafe_base64(32)
|
|
168
|
+
expires_at = Time.now + config[:session_ttl]
|
|
169
|
+
Session.create(subject: row[:email], token_hash: hash_token(session_raw), expires_at: expires_at)
|
|
170
|
+
|
|
171
|
+
{ token: session_raw, subject: row[:email], expires_at: expires_at, redirect_to: row[:redirect_to] }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def verify(raw)
|
|
175
|
+
ensure_configured!
|
|
176
|
+
return nil if raw.nil? || raw.empty?
|
|
177
|
+
|
|
178
|
+
row = Session.where(token_hash: hash_token(raw)).first
|
|
179
|
+
return nil unless row
|
|
180
|
+
return nil if row[:revoked_at]
|
|
181
|
+
return nil if row[:expires_at] <= Time.now
|
|
182
|
+
|
|
183
|
+
row[:subject]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def revoke(raw)
|
|
187
|
+
ensure_configured!
|
|
188
|
+
row = Session.where(token_hash: hash_token(raw)).first
|
|
189
|
+
return false unless row
|
|
190
|
+
|
|
191
|
+
Session.update(row[:id], revoked_at: Time.now)
|
|
192
|
+
true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def revoke_all(subject)
|
|
196
|
+
ensure_configured!
|
|
197
|
+
rows = Session.where(subject: subject).select { |row| row[:revoked_at].nil? }
|
|
198
|
+
now = Time.now
|
|
199
|
+
rows.each { |row| Session.update(row[:id], revoked_at: now) }
|
|
200
|
+
rows.size
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Deliberately not grown onto Model: a `<` comparison is real
|
|
204
|
+
# query-DSL scope for a hygiene task (docs/design/auth-sessions.md).
|
|
205
|
+
def sweep!
|
|
206
|
+
ensure_configured!
|
|
207
|
+
Monk::Persistence::Pg.checkout(config[:db_name]) do |conn|
|
|
208
|
+
now = Time.now
|
|
209
|
+
login_tokens_deleted = conn.exec_params("DELETE FROM login_tokens WHERE expires_at < $1", [now]).cmd_tuples
|
|
210
|
+
sessions_deleted = conn.exec_params("DELETE FROM sessions WHERE expires_at < $1", [now]).cmd_tuples
|
|
211
|
+
{ login_tokens: login_tokens_deleted, sessions: sessions_deleted }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Stateless double-submit CSRF token, derived not stored
|
|
216
|
+
# (docs/design/auth-sessions.md's "CSRF: stateless double-submit, no third
|
|
217
|
+
# table") -- the one HMAC implementation set_session_cookie and
|
|
218
|
+
# require_csrf! both call, so there's no second place this could
|
|
219
|
+
# drift out of sync.
|
|
220
|
+
def csrf_token_for(session_token)
|
|
221
|
+
OpenSSL::HMAC.hexdigest("SHA256", config[:secret], session_token)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# as_ansi spends two columns and one line per module, so even a
|
|
227
|
+
# short link fills the terminal. Half-block characters pack two
|
|
228
|
+
# module rows into one line and one column per module (roughly a
|
|
229
|
+
# quarter of the area); the explicit black-on-white colors keep it
|
|
230
|
+
# scannable on dark terminals too. Quiet zone is 2 modules, below
|
|
231
|
+
# the spec's 4 but reliable for phone cameras against a white
|
|
232
|
+
# background.
|
|
233
|
+
def compact_qr(qr, quiet_zone: 2)
|
|
234
|
+
width = qr.modules.size + quiet_zone * 2
|
|
235
|
+
blank = Array.new(quiet_zone) { Array.new(width, false) }
|
|
236
|
+
grid = blank + qr.modules.map { |row| Array.new(quiet_zone, false) + row + Array.new(quiet_zone, false) } + blank
|
|
237
|
+
grid << Array.new(width, false) if grid.size.odd?
|
|
238
|
+
|
|
239
|
+
grid.each_slice(2).map do |top, bottom|
|
|
240
|
+
cells = top.zip(bottom).map do |t, b|
|
|
241
|
+
if t && b then "\u2588"
|
|
242
|
+
elsif t then "\u2580"
|
|
243
|
+
elsif b then "\u2584"
|
|
244
|
+
else " "
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
# dark modules are the glyph (black fg) on a white bg
|
|
248
|
+
"\e[30;47m#{cells.join}\e[0m"
|
|
249
|
+
end.join("\n")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def ensure_configured!
|
|
253
|
+
raise Monk::AuthNotConfiguredError,
|
|
254
|
+
"Monk::Auth is not configured -- call Monk::Auth.configure first" unless @config
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def hash_token(raw)
|
|
258
|
+
Digest::SHA256.hexdigest(raw)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# rqrcode (used by log_dev_link) is a third-party gem with no
|
|
262
|
+
# Ractor awareness of its own: several of its lookup tables
|
|
263
|
+
# (RQRCodeCore::QRUtil::PATTERN_POSITION_TABLE and siblings) are
|
|
264
|
+
# ordinary, unfrozen constants. Reading one of those from a worker
|
|
265
|
+
# Ractor -- which is exactly what happens the first time
|
|
266
|
+
# log_dev_link runs inside a real request under Kino -- raises
|
|
267
|
+
# Ractor::IsolationError regardless of which Ractor originally
|
|
268
|
+
# required the gem; only the object's own shareability matters
|
|
269
|
+
# (confirmed live: requiring rqrcode in the main Ractor first does
|
|
270
|
+
# not help). Walking its constants here, at boot in the main
|
|
271
|
+
# Ractor, and freezing each one is the same fix
|
|
272
|
+
# docs/design/persistence-ractor-connections.md documents for this exact
|
|
273
|
+
# class of problem elsewhere in the stack. A no-op if the app
|
|
274
|
+
# hasn't added rqrcode to its own Gemfile (see log_dev_link).
|
|
275
|
+
def freeze_rqrcode!
|
|
276
|
+
require "rqrcode"
|
|
277
|
+
rescue LoadError
|
|
278
|
+
nil
|
|
279
|
+
else
|
|
280
|
+
freeze_constants!(RQRCodeCore)
|
|
281
|
+
freeze_constants!(RQRCode)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def freeze_constants!(mod, seen = {}.compare_by_identity)
|
|
285
|
+
return if seen[mod]
|
|
286
|
+
seen[mod] = true
|
|
287
|
+
|
|
288
|
+
mod.constants(false).each do |name|
|
|
289
|
+
value = mod.const_get(name)
|
|
290
|
+
if value.is_a?(Module)
|
|
291
|
+
freeze_constants!(value, seen) if value.name&.start_with?("RQRCode")
|
|
292
|
+
else
|
|
293
|
+
Ractor.make_shareable(value)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
Monk.freeze_hooks << self
|
|
300
|
+
end
|
|
301
|
+
end
|
data/lib/monk/base.rb
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
require_relative "freeze_hooks"
|
|
5
|
+
require_relative "environment"
|
|
6
|
+
|
|
7
|
+
module Monk
|
|
8
|
+
class Base
|
|
9
|
+
VERBS = %w[GET POST PUT PATCH DELETE].freeze
|
|
10
|
+
private_constant :VERBS
|
|
11
|
+
|
|
12
|
+
EMPTY_ARRAY = [].freeze
|
|
13
|
+
private_constant :EMPTY_ARRAY
|
|
14
|
+
|
|
15
|
+
# Action name -> path suffix (appended to the resource path) and verb(s)
|
|
16
|
+
# it dispatches on, for #resources. :update registers both PATCH and PUT --
|
|
17
|
+
# PATCH is the canonical partial-update verb, PUT accepted too since
|
|
18
|
+
# plenty of clients only ever send it for "update this resource".
|
|
19
|
+
REST_ACTIONS = {
|
|
20
|
+
index: { verbs: %w[GET], path: "" },
|
|
21
|
+
new: { verbs: %w[GET], path: "/new" },
|
|
22
|
+
create: { verbs: %w[POST], path: "" },
|
|
23
|
+
show: { verbs: %w[GET], path: "/:id" },
|
|
24
|
+
edit: { verbs: %w[GET], path: "/:id/edit" },
|
|
25
|
+
update: { verbs: %w[PATCH PUT], path: "/:id" },
|
|
26
|
+
destroy: { verbs: %w[DELETE], path: "/:id" },
|
|
27
|
+
}.freeze
|
|
28
|
+
private_constant :REST_ACTIONS
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
VERBS.each do |verb|
|
|
32
|
+
define_method(verb.downcase) do |path, &block|
|
|
33
|
+
routes << { verb: verb, path: path, block: block }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def routes
|
|
38
|
+
@routes ||= []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# EXPERIMENTAL: this is the one place Base's routing DSL departs
|
|
42
|
+
# from "verb(path) { block }" -- a controller class plus a list of
|
|
43
|
+
# action symbols, instead of a block. Not yet convinced that's the
|
|
44
|
+
# right shape (see monk-consumer-test session notes); may be
|
|
45
|
+
# reworked or removed rather than kept as-is.
|
|
46
|
+
#
|
|
47
|
+
# Registers a conventional set of REST routes rooted at `path` (e.g.
|
|
48
|
+
# "/orders" -> "/orders", "/orders/new", "/orders/:id", ...), each
|
|
49
|
+
# dispatching to `controller.new(context).public_send(action)`.
|
|
50
|
+
# `actions` defaults to all seven (index/new/create/show/edit/
|
|
51
|
+
# update/destroy); pass a subset to only wire up what `controller`
|
|
52
|
+
# actually implements -- an action not listed here raises
|
|
53
|
+
# ArgumentError immediately, rather than registering a route to a
|
|
54
|
+
# method that doesn't exist.
|
|
55
|
+
#
|
|
56
|
+
# Built entirely on top of #get/#post/#put/#patch/#delete above --
|
|
57
|
+
# no change to routing, dispatch, or freeze!, so an app that never
|
|
58
|
+
# calls #resources is unaffected.
|
|
59
|
+
def resources(path, controller, *actions)
|
|
60
|
+
actions = REST_ACTIONS.keys if actions.empty?
|
|
61
|
+
path = path.to_s.delete_prefix("/")
|
|
62
|
+
|
|
63
|
+
actions.each do |action|
|
|
64
|
+
mapping = REST_ACTIONS.fetch(action) do
|
|
65
|
+
raise ArgumentError, "unknown REST action #{action.inspect} (known: #{REST_ACTIONS.keys.join(", ")})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
route = "/#{path}#{mapping[:path]}"
|
|
69
|
+
mapping[:verbs].each { |verb| send(verb.downcase, route) { controller.new(self).public_send(action) } }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Where .erb templates live (default "views"), relative to the
|
|
74
|
+
# process's working directory and read at Boot.
|
|
75
|
+
def views(dir)
|
|
76
|
+
Monk::Views.root = dir
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Default layout template wrapped around every render, e.g.
|
|
80
|
+
# layout "layouts/app". Individual renders opt out with
|
|
81
|
+
# `render "x", layout: false`.
|
|
82
|
+
def layout(name)
|
|
83
|
+
Monk::Views.layout = name
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Where static files live (default "public"); `assets false`
|
|
87
|
+
# disables serving them.
|
|
88
|
+
def assets(dir)
|
|
89
|
+
Monk::Assets.root = dir
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def freeze!
|
|
93
|
+
routes.each do |route|
|
|
94
|
+
begin
|
|
95
|
+
Ractor.make_shareable(route[:block])
|
|
96
|
+
rescue ArgumentError => e
|
|
97
|
+
raise UnshareableRouteError, "#{route[:verb]} #{route[:path]} is not Ractor-shareable: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
error_handlers.each do |matcher, block|
|
|
102
|
+
begin
|
|
103
|
+
Ractor.make_shareable(block)
|
|
104
|
+
rescue ArgumentError => e
|
|
105
|
+
raise UnshareableRouteError, "error handler for #{matcher.inspect} is not Ractor-shareable: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
Monk.freeze!
|
|
110
|
+
|
|
111
|
+
# Settled once here, not read from ENV per request in
|
|
112
|
+
# #log_request: ENV is main-Ractor state, so a per-request read
|
|
113
|
+
# from a worker Ractor is the same hazard Monk::Assets already
|
|
114
|
+
# guards against the same way. Gates the $stdout echo only --
|
|
115
|
+
# Monk::Log's file write is unconditional, every environment.
|
|
116
|
+
@console_logging = Monk.env.development?
|
|
117
|
+
|
|
118
|
+
index_routes!
|
|
119
|
+
|
|
120
|
+
Ractor.make_shareable(routes)
|
|
121
|
+
Ractor.make_shareable(@static_routes)
|
|
122
|
+
Ractor.make_shareable(@dynamic_routes)
|
|
123
|
+
Ractor.make_shareable(error_handlers)
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def error(matcher, &block)
|
|
128
|
+
error_handlers << [matcher, block]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def error_handlers
|
|
132
|
+
@error_handlers ||= []
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def call(env)
|
|
136
|
+
freeze! unless Ractor.shareable?(routes)
|
|
137
|
+
|
|
138
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
|
+
status, headers, body = dispatch(env)
|
|
140
|
+
log_request(env, status, start)
|
|
141
|
+
[status, headers, body]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def dispatch(env)
|
|
147
|
+
# Assets before routes, the position Rack::Static would occupy in
|
|
148
|
+
# front of the app -- so a catch-all splat route can't shadow a
|
|
149
|
+
# stylesheet. The tradeoff, worth knowing: a route can't override
|
|
150
|
+
# a path that exists as a file.
|
|
151
|
+
asset = Monk::Assets.response(env)
|
|
152
|
+
return asset if asset
|
|
153
|
+
|
|
154
|
+
route, path_params = find_route(env["REQUEST_METHOD"], env["PATH_INFO"])
|
|
155
|
+
return not_found_response(env) unless route
|
|
156
|
+
|
|
157
|
+
params = parse_params(env).merge(path_params)
|
|
158
|
+
context = Context.new(params, env)
|
|
159
|
+
catch(:monk_halt) do
|
|
160
|
+
begin
|
|
161
|
+
body = context.instance_exec(context, &route[:block])
|
|
162
|
+
[200, context.headers, [body]]
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
handler = error_handlers.find { |matcher, _| matcher.is_a?(Class) && e.is_a?(matcher) }
|
|
165
|
+
if handler
|
|
166
|
+
context.status = 500
|
|
167
|
+
body = context.instance_exec(context, &handler.last)
|
|
168
|
+
[500, context.headers, [body]]
|
|
169
|
+
else
|
|
170
|
+
[500, { "content-type" => "application/json" }, ['{"error":"Internal Server Error"}']]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def log_request(env, status, start)
|
|
177
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
|
|
178
|
+
line = "#{Monk::Log.timestamp} #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} -> #{status} (#{duration_ms}ms)"
|
|
179
|
+
|
|
180
|
+
if @console_logging
|
|
181
|
+
$stdout.puts line
|
|
182
|
+
# Each worker Ractor buffers $stdout independently; without an explicit
|
|
183
|
+
# flush, lines only surface when the process exits, not in real time.
|
|
184
|
+
$stdout.flush
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
Monk::Log.write("#{line}\n")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def not_found_response(env)
|
|
191
|
+
handler = error_handlers.find { |matcher, _| matcher == 404 }
|
|
192
|
+
return [404, {}, [""]] unless handler
|
|
193
|
+
|
|
194
|
+
context = Context.new({}, env, status: 404)
|
|
195
|
+
catch(:monk_halt) do
|
|
196
|
+
body = context.instance_exec(context, &handler.last)
|
|
197
|
+
[404, context.headers, [body]]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Query string, then a JSON body on top (POST /auth/request's
|
|
202
|
+
# redirect_to; a callback token as ?token=... instead of a path
|
|
203
|
+
# segment -- docs/history/plan-auth.md Phase 9 step 29). Path segment params
|
|
204
|
+
# always win the final merge in #dispatch -- the route's own
|
|
205
|
+
# declared intent outranks anything a caller supplies.
|
|
206
|
+
def parse_params(env)
|
|
207
|
+
parse_query_string(env["QUERY_STRING"]).merge(parse_json_body(env))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# URI.decode_www_form, not Rack::Utils.parse_nested_query -- the
|
|
211
|
+
# latter memoizes an unfrozen QueryParser instance in a module ivar
|
|
212
|
+
# (Rack::Utils.default_query_parser) the moment "rack/utils" loads,
|
|
213
|
+
# in the main Ractor, and reading it back from a worker Ractor raises
|
|
214
|
+
# Ractor::IsolationError regardless of which Ractor set it. A bug in
|
|
215
|
+
# rack itself (not yet Ractor-safe), measured directly against a real
|
|
216
|
+
# worker Ractor rather than assumed -- no nested/array query syntax,
|
|
217
|
+
# consistent with Monk's minimal query surface elsewhere.
|
|
218
|
+
def parse_query_string(query_string)
|
|
219
|
+
return {} if query_string.to_s.empty?
|
|
220
|
+
|
|
221
|
+
URI.decode_www_form(query_string).each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def parse_json_body(env)
|
|
225
|
+
return {} unless env["CONTENT_TYPE"].to_s.include?("application/json")
|
|
226
|
+
|
|
227
|
+
body = env["rack.input"]&.read.to_s
|
|
228
|
+
return {} if body.empty?
|
|
229
|
+
|
|
230
|
+
JSON.parse(body, symbolize_names: true)
|
|
231
|
+
rescue JSON::ParserError
|
|
232
|
+
{}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Routes are static (literal path, no ":param" or trailing "*") in
|
|
236
|
+
# the overwhelming common case, so freeze! (#index_routes!) splits
|
|
237
|
+
# them into an O(1) exact-match table up front; only routes that
|
|
238
|
+
# actually need segment-by-segment matching pay for it at request
|
|
239
|
+
# time. Both structures hold the exact same route Hash objects that
|
|
240
|
+
# `routes` does, so making `routes` shareable would freeze them too
|
|
241
|
+
# -- but since they're not reachable *from* `routes`, they need
|
|
242
|
+
# their own Ractor.make_shareable call in #freeze!.
|
|
243
|
+
def index_routes!
|
|
244
|
+
static = {}
|
|
245
|
+
dynamic = {}
|
|
246
|
+
VERBS.each do |verb|
|
|
247
|
+
static[verb] = {}
|
|
248
|
+
dynamic[verb] = []
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
routes.each do |route|
|
|
252
|
+
segments = route[:path].split("/")
|
|
253
|
+
route[:segments] = segments
|
|
254
|
+
|
|
255
|
+
if segments.last == "*"
|
|
256
|
+
route[:prefix_segments] = segments[0...-1]
|
|
257
|
+
dynamic[route[:verb]] << route
|
|
258
|
+
elsif segments.any? { |s| s.start_with?(":") }
|
|
259
|
+
dynamic[route[:verb]] << route
|
|
260
|
+
else
|
|
261
|
+
static[route[:verb]][route[:path]] = route
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
@static_routes = static
|
|
266
|
+
@dynamic_routes = dynamic
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def find_route(verb, path)
|
|
270
|
+
static_route = @static_routes.dig(verb, path)
|
|
271
|
+
return [static_route, {}] if static_route
|
|
272
|
+
|
|
273
|
+
path_segments = path.split("/")
|
|
274
|
+
|
|
275
|
+
(@dynamic_routes[verb] || EMPTY_ARRAY).each do |route|
|
|
276
|
+
if route[:prefix_segments]
|
|
277
|
+
prefix = route[:prefix_segments]
|
|
278
|
+
next if path_segments.size < prefix.size
|
|
279
|
+
next unless segments_match?(prefix, path_segments)
|
|
280
|
+
|
|
281
|
+
params = extract_params(prefix, path_segments)
|
|
282
|
+
params[:splat] = path_segments[prefix.size..].join("/")
|
|
283
|
+
return [route, params]
|
|
284
|
+
else
|
|
285
|
+
route_segments = route[:segments]
|
|
286
|
+
next unless route_segments.size == path_segments.size
|
|
287
|
+
next unless segments_match?(route_segments, path_segments)
|
|
288
|
+
|
|
289
|
+
return [route, extract_params(route_segments, path_segments)]
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Boolean-only: whether a candidate route matches is decided before
|
|
297
|
+
# any params Hash is allocated, so a failed candidate costs nothing
|
|
298
|
+
# beyond the comparisons themselves.
|
|
299
|
+
def segments_match?(route_segments, path_segments)
|
|
300
|
+
route_segments.each_with_index.all? do |segment, i|
|
|
301
|
+
segment.start_with?(":") || segment == path_segments[i]
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def extract_params(route_segments, path_segments)
|
|
306
|
+
params = {}
|
|
307
|
+
route_segments.each_with_index do |segment, i|
|
|
308
|
+
params[segment[1..].to_sym] = path_segments[i] if segment.start_with?(":")
|
|
309
|
+
end
|
|
310
|
+
params
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def call(env)
|
|
315
|
+
self.class.call(env)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def freeze!
|
|
319
|
+
self.class.freeze!
|
|
320
|
+
self
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
data/lib/monk/context.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
require_relative "views"
|
|
4
|
+
require_relative "assets"
|
|
5
|
+
|
|
6
|
+
module Monk
|
|
7
|
+
class Context
|
|
8
|
+
include Monk::Views::Compiled
|
|
9
|
+
|
|
10
|
+
attr_reader :params, :env, :headers
|
|
11
|
+
attr_accessor :status
|
|
12
|
+
|
|
13
|
+
# Internal: true while a template is rendering, so the default layout
|
|
14
|
+
# wraps the page once and not every partial it renders.
|
|
15
|
+
attr_accessor :rendering
|
|
16
|
+
|
|
17
|
+
def initialize(params, env = {}, status: 200)
|
|
18
|
+
@params = params
|
|
19
|
+
@env = env
|
|
20
|
+
@status = status
|
|
21
|
+
@headers = {}
|
|
22
|
+
@rendering = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def header(name)
|
|
26
|
+
env["HTTP_#{name.upcase.tr("-", "_")}"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def halt(status, body = "")
|
|
30
|
+
throw :monk_halt, [status, headers, [body]]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def redirect(location, status: 302)
|
|
34
|
+
headers["location"] = location
|
|
35
|
+
throw :monk_halt, [status, headers, [""]]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def json(data)
|
|
39
|
+
headers["content-type"] = "application/json"
|
|
40
|
+
throw :monk_halt, [status, headers, [JSON.generate(data)]]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Renders a template and *returns* the HTML, unlike #json and #halt,
|
|
44
|
+
# which throw. That's what makes a partial work: a template rendering
|
|
45
|
+
# another template is this same call, and a route block's return value
|
|
46
|
+
# is already the response body.
|
|
47
|
+
#
|
|
48
|
+
# Data reaches a template two ways, both zero-machinery: ivars set in
|
|
49
|
+
# the route (the route block, the template and the layout all run with
|
|
50
|
+
# `self` bound to this same Context), and the `locals` hash passed
|
|
51
|
+
# here, which is what a partial rendered inside a loop wants.
|
|
52
|
+
def render(name, layout: :default, **locals)
|
|
53
|
+
headers["content-type"] ||= "text/html; charset=utf-8"
|
|
54
|
+
Monk::Views.render(self, name, locals, layout: layout)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def h(value)
|
|
58
|
+
Monk::Views.h(value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Marks a string as already-safe, so the implicit escaping around
|
|
62
|
+
# every `<%= %>` leaves it alone.
|
|
63
|
+
def raw(value)
|
|
64
|
+
Monk::Views::Raw.new(value.to_s)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def asset_path(path)
|
|
68
|
+
Monk::Assets.path_for(path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Same frozen values Monk::Settings[] reads, e.g. `settings[:api_key]`
|
|
72
|
+
# -- returns the module itself rather than duplicating its #[] here,
|
|
73
|
+
# so an undeclared key raises the exact same way through either path.
|
|
74
|
+
def settings
|
|
75
|
+
Monk::Settings
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|