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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +256 -33
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -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
- CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
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
- return [request, new_response]
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
- internal_store(method, url, status.to_i, content_type.to_s, body)
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
- # Network/db backends expire entries lazily (TTL) parity with
252
- # Python, whose non-memory backends return 0 from sweep.
253
- 0
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
- if Time.now.to_f > expires_at
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 CLASS-based middleware. Function-style middleware
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
- # Returns true if all class-based middleware passed, false if any
106
- # returned literal false (halt request).
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
- @middleware.each do |mw|
109
- next if Route.function_middleware?(mw)
110
- result = mw.call(request, response)
111
- return false if result == false
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
- # Check ANY routes first, then method-specific routes
449
- candidates = (method_index["ANY"] || []) + (method_index[normalized_method] || [])
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] = [] }