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.



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/tina4/router.rb', line 15

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
  @roles = []
  @perms = []
  @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

#permsObject (readonly)

RBAC guard groups (Feature 138 / ADR-0058). Each is an array of arrays: OR within a group, AND across groups (stacking).



13
14
15
# File 'lib/tina4/router.rb', line 13

def perms
  @perms
end

#rolesObject (readonly)

RBAC guard groups (Feature 138 / ADR-0058). Each is an array of arrays: OR within a group, AND across groups (stacking).



13
14
15
# File 'lib/tina4/router.rb', line 13

def roles
  @roles
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)


228
229
230
231
232
233
234
# File 'lib/tina4/router.rb', line 228

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)


211
212
213
214
215
216
217
218
# File 'lib/tina4/router.rb', line 211

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



77
78
79
80
# File 'lib/tina4/router.rb', line 77

def cache
  @cached = true
  self
end

#can(*permissions) ⇒ Object

RBAC: require ONE of the named permissions (OR). Reads the verified JWT permissions claim; granted-side wildcards (posts.*, *) satisfy a concrete requirement. Stack for AND. Implies auth. Feature 138. Returns self for chaining: Router.delete("/p") { ... }.can("posts.delete")



66
67
68
69
70
71
72
73
# File 'lib/tina4/router.rb', line 66

def can(*permissions)
  permissions = permissions.flatten.map(&:to_s).reject(&:empty?)
  unless permissions.empty?
    @perms << permissions
    @auth_required = true
  end
  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.



198
199
200
# File 'lib/tina4/router.rb', line 198

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.



187
188
189
190
# File 'lib/tina4/router.rb', line 187

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)


101
102
103
104
# File 'lib/tina4/router.rb', line 101

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



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/tina4/router.rb', line 107

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.



93
94
95
96
97
98
# File 'lib/tina4/router.rb', line 93

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



45
46
47
48
# File 'lib/tina4/router.rb', line 45

def no_auth
  @auth_required = false
  self
end

#role(*names) ⇒ Object

RBAC: require ONE of the named roles (OR). Reads the verified JWT roles claim. Stack .role/.can for AND. Implies auth. Feature 138 / ADR-0058. Returns self for chaining: Router.get("/admin") { ... }.role("admin")



53
54
55
56
57
58
59
60
# File 'lib/tina4/router.rb', line 53

def role(*names)
  names = names.flatten.map(&:to_s).reject(&:empty?)
  unless names.empty?
    @roles << names
    @auth_required = true
  end
  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.



175
176
177
178
179
180
181
182
# File 'lib/tina4/router.rb', line 175

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).



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/tina4/router.rb', line 156

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(request, 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



38
39
40
41
# File 'lib/tina4/router.rb', line 38

def secure
  @auth_required = true
  self
end