Class: Tina4::Route

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/router.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil) ⇒ Route

Returns a new instance of Route.



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/tina4/router.rb', line 12

def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
  @method = method.to_s.upcase.freeze
  @path = normalize_path(path).freeze
  @handler = handler
  @auth_handler = auth_handler
  @swagger_meta = swagger_meta
  @middleware = middleware.freeze
  @template = template&.freeze
  # Write routes are secure by default — bearer-token auth is enforced
  # on POST/PUT/PATCH/DELETE regardless of attached middleware.
  # Middleware is additive, never an auth bypass. tina4-book#141
  # PY-10-02. Call .no_auth on the route to opt out explicitly.
  @auth_required = %w[POST PUT PATCH DELETE].include?(@method)
  @cached = false
  @param_names = []
  @path_regex = compile_pattern(@path)
  @param_names.freeze
end

Instance Attribute Details

#auth_handlerObject (readonly)

Returns the value of attribute auth_handler.



8
9
10
# File 'lib/tina4/router.rb', line 8

def auth_handler
  @auth_handler
end

#auth_requiredObject

Returns the value of attribute auth_required.



10
11
12
# File 'lib/tina4/router.rb', line 10

def auth_required
  @auth_required
end

#cachedObject

Returns the value of attribute cached.



10
11
12
# File 'lib/tina4/router.rb', line 10

def cached
  @cached
end

#handlerObject (readonly)

Returns the value of attribute handler.



8
9
10
# File 'lib/tina4/router.rb', line 8

def handler
  @handler
end

#methodObject (readonly)

Returns the value of attribute method.



8
9
10
# File 'lib/tina4/router.rb', line 8

def method
  @method
end

#param_namesObject (readonly)

Returns the value of attribute param_names.



8
9
10
# File 'lib/tina4/router.rb', line 8

def param_names
  @param_names
end

#pathObject (readonly)

Returns the value of attribute path.



8
9
10
# File 'lib/tina4/router.rb', line 8

def path
  @path
end

#path_regexObject (readonly)

Returns the value of attribute path_regex.



8
9
10
# File 'lib/tina4/router.rb', line 8

def path_regex
  @path_regex
end

#swagger_metaObject (readonly)

Returns the value of attribute swagger_meta.



8
9
10
# File 'lib/tina4/router.rb', line 8

def swagger_meta
  @swagger_meta
end

#templateObject (readonly)

Returns the value of attribute template.



8
9
10
# File 'lib/tina4/router.rb', line 8

def template
  @template
end

Class Method Details

.filter_middleware?(mw) ⇒ Boolean

Detect "filter" middleware: a plain 2-arg callable (Proc/Lambda/Method) that receives (request, response) and halts by returning false.

This is the shape #run_middleware has always supported, kept working unchanged. It is NOT function middleware (3+ args, wraps the handler) and NOT class middleware (declares before_/after_ hooks) — a Class or Module is never a filter even when it defines .call, because its hooks are the documented mechanism.

Returns:

  • (Boolean)


198
199
200
201
202
203
204
# File 'lib/tina4/router.rb', line 198

def self.filter_middleware?(mw)
  return false if mw.is_a?(Class) || mw.is_a?(Module)

  mw.respond_to?(:arity) && mw.respond_to?(:call)
rescue StandardError
  false
end

.function_middleware?(mw) ⇒ Boolean

Detect Express/FastAPI-style function middleware.

A Proc/Lambda/Method whose arity indicates 3+ positional params (req, resp, next_handler). Ruby arity quirk: required-args-only arity is non-negative; if the callable accepts a splat or optionals, arity is negated (-required-1). We treat arity >= 3 OR arity <= -4 as function-style. Anything else (a class with before_/after_ methods, or a 2-arg callable) is treated as class-style and goes through #run_middleware.

Returns:

  • (Boolean)


181
182
183
184
185
186
187
188
# File 'lib/tina4/router.rb', line 181

def self.function_middleware?(mw)
  return false if mw.is_a?(Class) || mw.is_a?(Module)
  return false unless mw.respond_to?(:arity)
  ar = mw.arity
  ar >= 3 || ar <= -4
rescue StandardError
  false
end

Instance Method Details

#cacheObject

Mark this route as cacheable. Returns self for chaining: Router.get("/path") { ... }.cache



47
48
49
50
# File 'lib/tina4/router.rb', line 47

def cache
  @cached = true
  self
end

#function_middlewareObject

Function-style middleware attached to this route, in declaration order. The route dispatcher folds them into a Russian-doll continuation chain — first declared is the OUTERMOST layer (runs first on the way in, last on the way out). tina4-book#141 PY-10-01 — chapter 10 documented 8+ examples of function middleware for years; before this fix the framework silently ignored them.



168
169
170
# File 'lib/tina4/router.rb', line 168

def function_middleware
  @middleware.select { |mw| Route.function_middleware?(mw) }
end

#hook_middlewareObject

Per-route middleware that takes part in the before/after passes: everything except function-style middleware, with String specs resolved to the middleware they name.



157
158
159
160
# File 'lib/tina4/router.rb', line 157

def hook_middleware
  @middleware.reject { |mw| Route.function_middleware?(mw) }
             .map { |mw| mw.is_a?(::String) ? Router.resolve_string_middleware(mw) : mw }
end

#match?(request_path, request_method = nil) ⇒ Boolean

Returns params hash if matched, false otherwise

Returns:

  • (Boolean)


71
72
73
74
# File 'lib/tina4/router.rb', line 71

def match?(request_path, request_method = nil)
  return false if request_method && @method != "ANY" && @method != request_method.to_s.upcase
  match_path(request_path)
end

#match_path(request_path) ⇒ Object

Returns params hash if matched, false otherwise



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/tina4/router.rb', line 77

def match_path(request_path)
  match = @path_regex.match(request_path)
  return false unless match

  if @param_names.empty?
    {}
  else
    params = {}
    @param_names.each_with_index do |param_def, i|
      raw_value = match[i + 1]
      # Rack delivers PATH_INFO (and therefore these captures) as
      # ASCII-8BIT. Relabel as UTF-8 so an untyped param binds to SQL as
      # TEXT, not a BLOB: SQLite gives a BLOB no numeric affinity, so a
      # `{id}` bound as ASCII-8BIT never matches an INTEGER column
      # (GET /users/{id} 404s a real row). No transcode — URL path bytes
      # are already UTF-8. Parity: Python/PHP/Node path params are text.
      raw_value = raw_value.dup.force_encoding(Encoding::UTF_8) if raw_value.is_a?(::String)
      params[param_def[:name]] = cast_param(raw_value, param_def[:type])
    end
    params
  end
end

#middleware(*middleware_classes) ⇒ Object

Dual-mode: getter (no args) returns the middleware array; setter (with args) appends middleware and returns self for chaining. Router.post("/api") { ... }.middleware(AuthMiddleware)

Middleware is purely additive — registering middleware NEVER flips (POST/PUT/PATCH/DELETE) stays in effect; if a route truly wants to opt out of the built-in bearer check, call .no_auth explicitly. tina4-book#141 PY-10-02 — previously, attaching ANY middleware silently turned off auth_required, which let attackers bypass auth by routing through a logging middleware. Cross-framework parity.



63
64
65
66
67
68
# File 'lib/tina4/router.rb', line 63

def middleware(*middleware_classes)
  return @middleware if middleware_classes.empty?

  @middleware = @middleware.dup + middleware_classes
  self
end

#no_authObject

Opt out of the secure-by-default auth on write routes. Returns self for chaining: Router.post("/login") { ... }.no_auth



40
41
42
43
# File 'lib/tina4/router.rb', line 40

def no_auth
  @auth_required = false
  self
end

#run_after_middleware(request, response) ⇒ Object

Run per-route class middleware's after_* hooks, once the handler has run (or once a before_* halted). Same orchestrator, same discovery — see #run_middleware. Filter middleware has no after phase by construction.



145
146
147
148
149
150
151
152
# File 'lib/tina4/router.rb', line 145

def run_after_middleware(request, response)
  hook_middleware.each do |mw|
    next if Route.filter_middleware?(mw)

    Tina4::Middleware.run_after([mw], request, response)
  end
  response
end

#run_middleware(request, response) ⇒ Object

Run per-route middleware BEFORE the handler.

Dispatch is by SHAPE, in declaration order:

* a CLASS (or instance) declaring before_*/after_* hooks — handed to
Tina4::Middleware.run_before, the SAME orchestrator global middleware
goes through, so it gets the SAME hook discovery (definition order,
base->derived) and the SAME return-value table. There is no second,
divergent runner here.
* a String spec ("ResponseCache", "ResponseCache:300") — resolved to the
configured middleware first (parity with Python/PHP/Node).
* a 2-arg callable ("filter" middleware) — called directly; false halts.

Function-style middleware (3+ args: req, resp, next_handler) is NOT run here — it wraps the handler in a continuation chain (see #function_middleware and DispatchPipeline#invoke_route_handler).

This method used to call mw.call(request, response) on EVERYTHING. A class declaring def self.before_auth does not respond to .call, so per-route class middleware raised NoMethodError and the dispatcher turned every such request into a clean 500 — the documented per-route before_/after_ mechanism never ran at all. The .call-everything body is superseded by the shape dispatch above; the 2-arg filter path below is what remains of it.

Returns true if every middleware passed, false to halt (handler skipped).



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/tina4/router.rb', line 126

def run_middleware(request, response)
  hook_middleware.each do |mw|
    if Route.filter_middleware?(mw)
      next unless mw.call(request, response) == false

      # Same `false` row of the return-value table a before_* hook gets:
      # keep the response the filter set, 403 only if it set nothing.
      Tina4::Middleware.refuse(response)
      return false
    else
      return false unless Tina4::Middleware.run_before([mw], request, response)
    end
  end
  true
end

#secureObject

Mark this route as requiring bearer-token authentication. Returns self for chaining: Router.get("/path") { ... }.secure



33
34
35
36
# File 'lib/tina4/router.rb', line 33

def secure
  @auth_required = true
  self
end