tina4ruby 3.13.94 → 3.13.97
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 +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +256 -33
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/response_cache.rb
CHANGED
|
@@ -37,7 +37,10 @@ module Tina4
|
|
|
37
37
|
# TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD — credentials when not in the URL
|
|
38
38
|
#
|
|
39
39
|
class ResponseCache
|
|
40
|
-
|
|
40
|
+
# vary / vary_values carry the RFC 9111 s4.1 bookkeeping: the field names
|
|
41
|
+
# the origin nominated, and the values they had on the request that caused
|
|
42
|
+
# this response to be stored.
|
|
43
|
+
CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at, :vary, :vary_values)
|
|
41
44
|
|
|
42
45
|
# @param ttl [Integer] default TTL in seconds (0 = disabled)
|
|
43
46
|
# @param max_entries [Integer] maximum cache entries
|
|
@@ -82,12 +85,16 @@ module Tina4
|
|
|
82
85
|
return [request, response] unless method == "GET"
|
|
83
86
|
|
|
84
87
|
url = request.respond_to?(:url) ? request.url : (request.respond_to?(:path) ? request.path : "/")
|
|
85
|
-
hit = internal_lookup(method, url)
|
|
88
|
+
hit = internal_lookup(method, url, request)
|
|
86
89
|
if hit
|
|
87
90
|
if response.respond_to?(:call)
|
|
88
91
|
new_response = response.call(hit.body, hit.status_code, hit.content_type)
|
|
89
92
|
set_cache_headers(new_response, "HIT", remaining_ttl(hit.expires_at))
|
|
90
|
-
|
|
93
|
+
# Returning the Response OBJECT is what short-circuits. Returning the
|
|
94
|
+
# [request, response] pair only REBINDS and continues, so the handler
|
|
95
|
+
# ran anyway and the cache never saved a single call -- while still
|
|
96
|
+
# stamping X-Cache: HIT, which made the header a lie.
|
|
97
|
+
return new_response
|
|
91
98
|
end
|
|
92
99
|
end
|
|
93
100
|
|
|
@@ -138,13 +145,43 @@ module Tina4
|
|
|
138
145
|
response.to_s
|
|
139
146
|
end
|
|
140
147
|
|
|
141
|
-
|
|
148
|
+
# This response came OUT of the cache; re-storing it would also overwrite
|
|
149
|
+
# its X-Cache: HIT header with MISS.
|
|
150
|
+
return [request, response] if header_value(response, "X-Cache") == "HIT"
|
|
151
|
+
|
|
152
|
+
internal_store(method, url, status.to_i, content_type.to_s, body, request: request, response: response)
|
|
142
153
|
# The handler ran (cache miss) — annotate the response so clients can
|
|
143
154
|
# see this was a fresh response and how long it will be cached.
|
|
144
155
|
set_cache_headers(response, "MISS", @ttl)
|
|
145
156
|
[request, response]
|
|
146
157
|
end
|
|
147
158
|
|
|
159
|
+
# ── Class-level hooks, so `middleware: [Tina4::ResponseCache]` works ──
|
|
160
|
+
|
|
161
|
+
class << self
|
|
162
|
+
# Middleware.discover_methods walks klass.singleton_class and calls
|
|
163
|
+
# klass.send(name, ...) -- it finds CLASS methods only. before_cache and
|
|
164
|
+
# after_cache are INSTANCE methods, so attaching the class discovered no
|
|
165
|
+
# hooks at all and the cache was a SILENT no-op: no warning, no header,
|
|
166
|
+
# no caching. These two delegate to the module-level singleton (the same
|
|
167
|
+
# instance Tina4.cache_get uses), which reads TTL and backend from ENV.
|
|
168
|
+
#
|
|
169
|
+
# @param request [Object]
|
|
170
|
+
# @param response [Object]
|
|
171
|
+
# @return [Tina4::Response, Array] the Response on a HIT (short-circuit),
|
|
172
|
+
# otherwise the [request, response] pair
|
|
173
|
+
def before_response_cache(request, response)
|
|
174
|
+
Tina4.cache_instance.before_cache(request, response)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @param request [Object]
|
|
178
|
+
# @param response [Object]
|
|
179
|
+
# @return [Array] the [request, response] pair
|
|
180
|
+
def after_response_cache(request, response)
|
|
181
|
+
Tina4.cache_instance.after_cache(request, response)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
148
185
|
# ── Direct Cache API (same across all 4 languages) ──────────
|
|
149
186
|
|
|
150
187
|
# Get a value from the cache by key.
|
|
@@ -244,13 +281,12 @@ module Tina4
|
|
|
244
281
|
keys_to_remove.each { |k| @store.delete(k) }
|
|
245
282
|
keys_to_remove.size
|
|
246
283
|
end
|
|
247
|
-
elsif @backend.respond_to?(:sweep)
|
|
248
|
-
# The file backend supports an explicit sweep that returns a count.
|
|
249
|
-
@backend.sweep
|
|
250
284
|
else
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
|
|
285
|
+
# Every backend answers sweep now - the base class returns 0 for the
|
|
286
|
+
# providers that expire server-side, and memory/file/database return a
|
|
287
|
+
# real count. The respond_to?(:sweep) guard that used to be here could
|
|
288
|
+
# not tell "not supported" from "evicted nothing".
|
|
289
|
+
@backend.sweep
|
|
254
290
|
end
|
|
255
291
|
end
|
|
256
292
|
|
|
@@ -305,7 +341,7 @@ module Tina4
|
|
|
305
341
|
end
|
|
306
342
|
|
|
307
343
|
# Internal: retrieve a cached response. Used by middleware hooks only.
|
|
308
|
-
def internal_lookup(method, url)
|
|
344
|
+
def internal_lookup(method, url, request = nil)
|
|
309
345
|
return nil unless enabled?
|
|
310
346
|
return nil unless method == "GET"
|
|
311
347
|
|
|
@@ -319,8 +355,8 @@ module Tina4
|
|
|
319
355
|
|
|
320
356
|
# For memory backend, entry is a CacheEntry; for others, reconstruct
|
|
321
357
|
if entry.is_a?(CacheEntry)
|
|
322
|
-
if Time.now.to_f > entry.expires_at
|
|
323
|
-
backend_delete(key)
|
|
358
|
+
if Time.now.to_f > entry.expires_at || !vary_matches?(entry, request)
|
|
359
|
+
backend_delete(key) if Time.now.to_f > entry.expires_at
|
|
324
360
|
@mutex.synchronize { @misses += 1 }
|
|
325
361
|
return nil
|
|
326
362
|
end
|
|
@@ -328,36 +364,44 @@ module Tina4
|
|
|
328
364
|
entry
|
|
329
365
|
elsif entry.is_a?(Hash)
|
|
330
366
|
expires_at = entry["expires_at"] || entry[:expires_at] || 0
|
|
331
|
-
|
|
332
|
-
backend_delete(key)
|
|
333
|
-
@mutex.synchronize { @misses += 1 }
|
|
334
|
-
return nil
|
|
335
|
-
end
|
|
336
|
-
@mutex.synchronize { @hits += 1 }
|
|
337
|
-
CacheEntry.new(
|
|
367
|
+
rebuilt = CacheEntry.new(
|
|
338
368
|
entry["body"] || entry[:body],
|
|
339
369
|
entry["content_type"] || entry[:content_type],
|
|
340
370
|
entry["status_code"] || entry[:status_code],
|
|
341
|
-
expires_at
|
|
371
|
+
expires_at,
|
|
372
|
+
entry["vary"] || entry[:vary] || [],
|
|
373
|
+
entry["vary_values"] || entry[:vary_values] || {}
|
|
342
374
|
)
|
|
375
|
+
if Time.now.to_f > expires_at || !vary_matches?(rebuilt, request)
|
|
376
|
+
backend_delete(key) if Time.now.to_f > expires_at
|
|
377
|
+
@mutex.synchronize { @misses += 1 }
|
|
378
|
+
return nil
|
|
379
|
+
end
|
|
380
|
+
@mutex.synchronize { @hits += 1 }
|
|
381
|
+
rebuilt
|
|
343
382
|
end
|
|
344
383
|
end
|
|
345
384
|
|
|
346
385
|
# Internal: store a response in the cache. Used by middleware hooks only.
|
|
347
|
-
def internal_store(method, url, status_code, content_type, body, ttl: nil)
|
|
386
|
+
def internal_store(method, url, status_code, content_type, body, ttl: nil, request: nil, response: nil)
|
|
348
387
|
return unless enabled?
|
|
349
388
|
return unless method == "GET"
|
|
350
389
|
return unless @status_codes.include?(status_code)
|
|
390
|
+
return unless may_store?(request, response)
|
|
351
391
|
|
|
352
392
|
effective_ttl = ttl || @ttl
|
|
353
393
|
key = cache_key(method, url)
|
|
354
394
|
expires_at = Time.now.to_f + effective_ttl
|
|
395
|
+
vary = vary_fields(response)
|
|
396
|
+
vary_values = vary.each_with_object({}) { |f, h| h[f] = header_value(request, f) }
|
|
355
397
|
|
|
356
398
|
entry_data = {
|
|
357
399
|
"body" => body,
|
|
358
400
|
"content_type" => content_type,
|
|
359
401
|
"status_code" => status_code,
|
|
360
|
-
"expires_at" => expires_at
|
|
402
|
+
"expires_at" => expires_at,
|
|
403
|
+
"vary" => vary,
|
|
404
|
+
"vary_values" => vary_values
|
|
361
405
|
}
|
|
362
406
|
|
|
363
407
|
case @backend_name
|
|
@@ -367,13 +411,87 @@ module Tina4
|
|
|
367
411
|
oldest_key = @store.keys.first
|
|
368
412
|
@store.delete(oldest_key)
|
|
369
413
|
end
|
|
370
|
-
@store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
|
|
414
|
+
@store[key] = CacheEntry.new(body, content_type, status_code, expires_at, vary, vary_values)
|
|
371
415
|
end
|
|
372
416
|
else
|
|
373
417
|
backend_set(key, entry_data, effective_ttl)
|
|
374
418
|
end
|
|
375
419
|
end
|
|
376
420
|
|
|
421
|
+
# ── RFC 9111 conformance (shared-cache rules) ──────────────
|
|
422
|
+
|
|
423
|
+
# Response directives that let a SHARED cache store a response to a request
|
|
424
|
+
# carrying Authorization (RFC 9111 s3.5).
|
|
425
|
+
SHARED_CACHE_DIRECTIVES = %w[public s-maxage must-revalidate].freeze
|
|
426
|
+
|
|
427
|
+
# Case-insensitive header lookup on a request or response.
|
|
428
|
+
#
|
|
429
|
+
# @param carrier [Object, nil]
|
|
430
|
+
# @param name [String]
|
|
431
|
+
# @return [String, nil]
|
|
432
|
+
def header_value(carrier, name)
|
|
433
|
+
return nil if carrier.nil?
|
|
434
|
+
return nil unless carrier.respond_to?(:headers)
|
|
435
|
+
|
|
436
|
+
headers = carrier.headers
|
|
437
|
+
return nil unless headers.respond_to?(:each)
|
|
438
|
+
|
|
439
|
+
target = name.downcase
|
|
440
|
+
headers.each do |key, value|
|
|
441
|
+
return value.to_s if key.to_s.downcase == target
|
|
442
|
+
end
|
|
443
|
+
nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# The lower-cased field names in a response's Vary header.
|
|
447
|
+
#
|
|
448
|
+
# @param response [Object, nil]
|
|
449
|
+
# @return [Array<String>]
|
|
450
|
+
def vary_fields(response)
|
|
451
|
+
raw = header_value(response, "Vary")
|
|
452
|
+
return [] if raw.nil? || raw.strip.empty?
|
|
453
|
+
|
|
454
|
+
raw.split(",").map { |f| f.strip.downcase }.reject(&:empty?)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# May a SHARED cache store this response? (RFC 9111 s3, s4.1)
|
|
458
|
+
#
|
|
459
|
+
# s3 -- "if the cache is shared: the Authorization header field is not
|
|
460
|
+
# present in the request ... or a response directive is present that
|
|
461
|
+
# explicitly allows shared caching". The key is method + URL only, so
|
|
462
|
+
# without this one authenticated caller's body is replayed to the next.
|
|
463
|
+
#
|
|
464
|
+
# s4.1 -- a stored response whose Vary contains "*" "always fails to
|
|
465
|
+
# match", so storing one is pointless.
|
|
466
|
+
#
|
|
467
|
+
# @param request [Object, nil]
|
|
468
|
+
# @param response [Object, nil]
|
|
469
|
+
# @return [Boolean]
|
|
470
|
+
def may_store?(request, response)
|
|
471
|
+
return false if vary_fields(response).include?("*")
|
|
472
|
+
return true if header_value(request, "Authorization").nil?
|
|
473
|
+
|
|
474
|
+
cache_control = header_value(response, "Cache-Control").to_s.downcase
|
|
475
|
+
SHARED_CACHE_DIRECTIVES.any? { |directive| cache_control.include?(directive) }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Do the nominated request headers match the ones recorded on the entry?
|
|
479
|
+
#
|
|
480
|
+
# RFC 9111 s4.1 -- the cache MUST NOT use a stored response unless every
|
|
481
|
+
# request header field nominated by its Vary value matches. An absent field
|
|
482
|
+
# only matches an absent field.
|
|
483
|
+
#
|
|
484
|
+
# @param entry [CacheEntry]
|
|
485
|
+
# @param request [Object, nil]
|
|
486
|
+
# @return [Boolean]
|
|
487
|
+
def vary_matches?(entry, request)
|
|
488
|
+
vary = entry.respond_to?(:vary) ? (entry.vary || []) : []
|
|
489
|
+
return true if vary.empty?
|
|
490
|
+
|
|
491
|
+
recorded = entry.vary_values || {}
|
|
492
|
+
vary.all? { |field| header_value(request, field) == (recorded[field] || recorded[field.to_sym]) }
|
|
493
|
+
end
|
|
494
|
+
|
|
377
495
|
# ── Backend initialization ─────────────────────────────────
|
|
378
496
|
|
|
379
497
|
# Build the storage backend. Memory keeps an in-process LRU store on the
|
data/lib/tina4/router.rb
CHANGED
|
@@ -97,22 +97,68 @@ module Tina4
|
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
# Run per-route
|
|
101
|
-
# (Proc/Lambda taking 3+ args: req, resp, next_handler) is skipped
|
|
102
|
-
# here — it's dispatched separately via #function_middleware which
|
|
103
|
-
# wraps the route handler in a continuation chain (see rack_app.rb).
|
|
100
|
+
# Run per-route middleware BEFORE the handler.
|
|
104
101
|
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
102
|
+
# Dispatch is by SHAPE, in declaration order:
|
|
103
|
+
#
|
|
104
|
+
# * a CLASS (or instance) declaring before_*/after_* hooks — handed to
|
|
105
|
+
# Tina4::Middleware.run_before, the SAME orchestrator global middleware
|
|
106
|
+
# goes through, so it gets the SAME hook discovery (definition order,
|
|
107
|
+
# base->derived) and the SAME return-value table. There is no second,
|
|
108
|
+
# divergent runner here.
|
|
109
|
+
# * a String spec ("ResponseCache", "ResponseCache:300") — resolved to the
|
|
110
|
+
# configured middleware first (parity with Python/PHP/Node).
|
|
111
|
+
# * a 2-arg callable ("filter" middleware) — called directly; false halts.
|
|
112
|
+
#
|
|
113
|
+
# Function-style middleware (3+ args: req, resp, next_handler) is NOT run
|
|
114
|
+
# here — it wraps the handler in a continuation chain (see
|
|
115
|
+
# #function_middleware and DispatchPipeline#invoke_route_handler).
|
|
116
|
+
#
|
|
117
|
+
# This method used to call `mw.call(request, response)` on EVERYTHING. A
|
|
118
|
+
# class declaring `def self.before_auth` does not respond to .call, so
|
|
119
|
+
# per-route class middleware raised NoMethodError and the dispatcher turned
|
|
120
|
+
# every such request into a clean 500 — the documented per-route
|
|
121
|
+
# before_*/after_* mechanism never ran at all. The .call-everything body is
|
|
122
|
+
# superseded by the shape dispatch above; the 2-arg filter path below is
|
|
123
|
+
# what remains of it.
|
|
124
|
+
#
|
|
125
|
+
# Returns true if every middleware passed, false to halt (handler skipped).
|
|
107
126
|
def run_middleware(request, response)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
127
|
+
hook_middleware.each do |mw|
|
|
128
|
+
if Route.filter_middleware?(mw)
|
|
129
|
+
next unless mw.call(request, response) == false
|
|
130
|
+
|
|
131
|
+
# Same `false` row of the return-value table a before_* hook gets:
|
|
132
|
+
# keep the response the filter set, 403 only if it set nothing.
|
|
133
|
+
Tina4::Middleware.refuse(response)
|
|
134
|
+
return false
|
|
135
|
+
else
|
|
136
|
+
return false unless Tina4::Middleware.run_before([mw], request, response)
|
|
137
|
+
end
|
|
112
138
|
end
|
|
113
139
|
true
|
|
114
140
|
end
|
|
115
141
|
|
|
142
|
+
# Run per-route class middleware's after_* hooks, once the handler has run
|
|
143
|
+
# (or once a before_* halted). Same orchestrator, same discovery — see
|
|
144
|
+
# #run_middleware. Filter middleware has no after phase by construction.
|
|
145
|
+
def run_after_middleware(request, response)
|
|
146
|
+
hook_middleware.each do |mw|
|
|
147
|
+
next if Route.filter_middleware?(mw)
|
|
148
|
+
|
|
149
|
+
Tina4::Middleware.run_after([mw], request, response)
|
|
150
|
+
end
|
|
151
|
+
response
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Per-route middleware that takes part in the before/after passes:
|
|
155
|
+
# everything except function-style middleware, with String specs resolved
|
|
156
|
+
# to the middleware they name.
|
|
157
|
+
def hook_middleware
|
|
158
|
+
@middleware.reject { |mw| Route.function_middleware?(mw) }
|
|
159
|
+
.map { |mw| mw.is_a?(::String) ? Router.resolve_string_middleware(mw) : mw }
|
|
160
|
+
end
|
|
161
|
+
|
|
116
162
|
# Function-style middleware attached to this route, in declaration
|
|
117
163
|
# order. The route dispatcher folds them into a Russian-doll
|
|
118
164
|
# continuation chain — first declared is the OUTERMOST layer (runs
|
|
@@ -141,6 +187,22 @@ module Tina4
|
|
|
141
187
|
false
|
|
142
188
|
end
|
|
143
189
|
|
|
190
|
+
# Detect "filter" middleware: a plain 2-arg callable (Proc/Lambda/Method)
|
|
191
|
+
# that receives (request, response) and halts by returning false.
|
|
192
|
+
#
|
|
193
|
+
# This is the shape #run_middleware has always supported, kept working
|
|
194
|
+
# unchanged. It is NOT function middleware (3+ args, wraps the handler) and
|
|
195
|
+
# NOT class middleware (declares before_*/after_* hooks) — a Class or Module
|
|
196
|
+
# is never a filter even when it defines .call, because its hooks are the
|
|
197
|
+
# documented mechanism.
|
|
198
|
+
def self.filter_middleware?(mw)
|
|
199
|
+
return false if mw.is_a?(Class) || mw.is_a?(Module)
|
|
200
|
+
|
|
201
|
+
mw.respond_to?(:arity) && mw.respond_to?(:call)
|
|
202
|
+
rescue StandardError
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
144
206
|
private
|
|
145
207
|
|
|
146
208
|
def normalize_path(path)
|
|
@@ -295,6 +357,22 @@ module Tina4
|
|
|
295
357
|
end
|
|
296
358
|
|
|
297
359
|
module Router
|
|
360
|
+
# Known string-addressable middleware, for a route declared as
|
|
361
|
+
# `middleware: ["ResponseCache:300"]`. Matches PHP
|
|
362
|
+
# (Router::resolveStringMiddleware) and Node (resolveStringMiddleware),
|
|
363
|
+
# which both know exactly one name today. Python's registry is larger;
|
|
364
|
+
# unifying the three registries is scheduled separately, so this
|
|
365
|
+
# deliberately does NOT guess at Python's extra names.
|
|
366
|
+
#
|
|
367
|
+
# Each entry is a builder taking the parsed colon-args. See
|
|
368
|
+
# .resolve_string_middleware.
|
|
369
|
+
STRING_MIDDLEWARE = {
|
|
370
|
+
"ResponseCache" => lambda { |args|
|
|
371
|
+
ttl = args.first
|
|
372
|
+
ttl.to_s.match?(/\A\d+\z/) ? Tina4::ResponseCache.new(ttl: ttl.to_i) : Tina4::ResponseCache.new
|
|
373
|
+
}
|
|
374
|
+
}.freeze
|
|
375
|
+
|
|
298
376
|
class << self
|
|
299
377
|
def routes
|
|
300
378
|
@routes ||= []
|
|
@@ -445,8 +523,23 @@ module Tina4
|
|
|
445
523
|
normalized_path = "/#{normalized_path}" unless normalized_path.start_with?("/")
|
|
446
524
|
normalized_path = normalized_path.chomp("/") unless normalized_path == "/"
|
|
447
525
|
|
|
448
|
-
#
|
|
449
|
-
|
|
526
|
+
# Candidates in REGISTRATION order, which is what Python, PHP and Node
|
|
527
|
+
# all do. This used to be `ANY + method`, which made an ANY route beat
|
|
528
|
+
# every same-path specific route no matter when either was registered -
|
|
529
|
+
# so an app with an ordinary CMS catch-all (`any("/{slug}")`) silently
|
|
530
|
+
# swallowed the framework's own GET routes, `/__health` among them. The
|
|
531
|
+
# route was registered correctly; the router simply never reached it.
|
|
532
|
+
#
|
|
533
|
+
# `routes` is the registration-order array and `method_index` is the
|
|
534
|
+
# per-method fast path. With no ANY routes registered - the common case -
|
|
535
|
+
# the fast path is exactly what it was. Only an app that actually uses
|
|
536
|
+
# ANY pays for the ordered scan, and only that app needed it.
|
|
537
|
+
any_routes = method_index["ANY"]
|
|
538
|
+
candidates = if any_routes.nil? || any_routes.empty?
|
|
539
|
+
method_index[normalized_method] || []
|
|
540
|
+
else
|
|
541
|
+
routes.select { |r| r.method == "ANY" || r.method == normalized_method }
|
|
542
|
+
end
|
|
450
543
|
candidates.each do |route|
|
|
451
544
|
params = route.match_path(normalized_path)
|
|
452
545
|
return [route, params] if params
|
|
@@ -534,6 +627,42 @@ module Tina4
|
|
|
534
627
|
Tina4::Middleware.use(klass)
|
|
535
628
|
end
|
|
536
629
|
|
|
630
|
+
# Resolve a string middleware spec to the middleware it names.
|
|
631
|
+
#
|
|
632
|
+
# "ResponseCache" -> ResponseCache with the default/env TTL
|
|
633
|
+
# "ResponseCache:300" -> ResponseCache with ttl = 300
|
|
634
|
+
#
|
|
635
|
+
# The head before the first ":" is the name; the colon-separated tail is
|
|
636
|
+
# its arguments. Same parse as Python's _resolve_string_middleware, PHP's
|
|
637
|
+
# Router::resolveStringMiddleware and Node's resolveStringMiddleware.
|
|
638
|
+
#
|
|
639
|
+
# ONE INSTANCE PER SPEC. Route middleware is resolved per dispatch in
|
|
640
|
+
# Ruby, so without memoising, every request would build a fresh
|
|
641
|
+
# ResponseCache with a fresh empty store and the cache could never hit.
|
|
642
|
+
# PHP memoises for exactly this reason; Python gets it for free by
|
|
643
|
+
# resolving once at registration.
|
|
644
|
+
#
|
|
645
|
+
# An unknown name RAISES, naming the known set — never a silent skip.
|
|
646
|
+
# Python raises ValueError, Node throws; a typo must surface, not
|
|
647
|
+
# quietly drop the middleware (which for an auth middleware would mean
|
|
648
|
+
# serving the route unprotected).
|
|
649
|
+
def resolve_string_middleware(spec)
|
|
650
|
+
spec = spec.to_s
|
|
651
|
+
@resolved_string_middleware ||= {}
|
|
652
|
+
return @resolved_string_middleware[spec] if @resolved_string_middleware.key?(spec)
|
|
653
|
+
|
|
654
|
+
name, _sep, tail = spec.partition(":")
|
|
655
|
+
builder = STRING_MIDDLEWARE[name]
|
|
656
|
+
unless builder
|
|
657
|
+
raise ArgumentError,
|
|
658
|
+
"Unknown middleware #{name.inspect}. Known string middleware: " \
|
|
659
|
+
"#{STRING_MIDDLEWARE.keys.sort.join(', ')}. For custom middleware, " \
|
|
660
|
+
"pass the class directly to .middleware(MyMiddleware)."
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
@resolved_string_middleware[spec] = builder.call(tail.empty? ? [] : tail.split(":"))
|
|
664
|
+
end
|
|
665
|
+
|
|
537
666
|
def clear!
|
|
538
667
|
@routes = []
|
|
539
668
|
@method_index = Hash.new { |h, k| h[k] = [] }
|