tina4ruby 3.13.2 → 3.13.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9136344be97c23d23b85f26a14f5a992cf7df49c16ff6d8a60c928461ab222b
4
- data.tar.gz: ea8eb37ac2e183ff58551b4e513e37359075dd1aab8672ea16a02d99acae9980
3
+ metadata.gz: f850a40f190c65782dd6f995b210335fa8b5c230c241b7d7031e34c0118f5d9e
4
+ data.tar.gz: 15a8e7d8cfdadd898917d1604d64cfd3f84dcc83838f5c4bf2a7f2c7ec66df3c
5
5
  SHA512:
6
- metadata.gz: 1317a3d0298906f4470f46cb86288b1b0573364028b7d15244aa04447b69509132c7f443924368c75bb1461b146ff93ca5d0d8a418fa29825eb120221c72e135
7
- data.tar.gz: fadb37d5d3a570212a75eb5f683fbf78bfe4af0e78128d96fb293c355c66631bd3bfad570a3d320e47c30da2209ec01ebacfe2be26edc1dc2c7dec13a819c0d0
6
+ metadata.gz: a457acd04c35cfd41a88def3fae8717b054a131fdd674d00eff2eda4bdc5a6084918b5f40622090613f7009eb7007b0135ff0b0cb3df0749d1def6b7ca0834cd
7
+ data.tar.gz: 65d449d0775ed2599739c7fceffe4f74fa1b0c488cb85a09e257070a3b6c1aa59c85e4d56a5bf0a84c6ad239b2e729099b29c8b9d5edf1719bf59738fe8852af
data/lib/tina4/env.rb CHANGED
@@ -83,6 +83,14 @@ module Tina4
83
83
  "TINA4_SECRET" => "tina4-secret-change-me"
84
84
  }.freeze
85
85
 
86
+ # Typed env-var coercion — parity with tina4_python's Env class,
87
+ # tina4-php's Tina4\Env, and tina4-nodejs's Env. Truthy values
88
+ # (case-insensitive after strip): "1", "true", "on", "yes", "y", "t".
89
+ # Falsy: "0", "false", "off", "no", "n", "f", empty string. Anything
90
+ # else falls through to default.
91
+ TRUTHY = %w[1 true on yes y t].freeze
92
+ FALSY = %w[0 false off no n f].freeze
93
+
86
94
  # Check if a value is truthy for env boolean checks.
87
95
  #
88
96
  # Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
@@ -91,6 +99,58 @@ module Tina4
91
99
  %w[true 1 yes on].include?(val.to_s.strip.downcase)
92
100
  end
93
101
 
102
+ # Read an env var and coerce to Boolean. Returns +default+ when the
103
+ # var is unset or holds a value outside the TRUTHY/FALSY tables.
104
+ # Never raises — bad input falls through to default.
105
+ def self.bool(name, default: false)
106
+ raw = ENV[name.to_s]
107
+ return default if raw.nil?
108
+ token = raw.strip.downcase
109
+ return true if TRUTHY.include?(token)
110
+ return false if FALSY.include?(token) || token.empty?
111
+ default
112
+ end
113
+
114
+ # Read an env var and coerce to Integer. Logs a warning via Tina4::Log
115
+ # (if loaded) and returns +default+ on parse failure. Never raises.
116
+ def self.int(name, default: 0)
117
+ raw = ENV[name.to_s]
118
+ return default if raw.nil?
119
+ Integer(raw.strip)
120
+ rescue ArgumentError, TypeError
121
+ log_warning("Env.int(#{name.inspect}): could not parse #{raw.inspect} as Integer — using default #{default.inspect}")
122
+ default
123
+ end
124
+
125
+ # Read an env var and coerce to Float. Logs a warning via Tina4::Log
126
+ # (if loaded) and returns +default+ on parse failure. Never raises.
127
+ def self.float(name, default: 0.0)
128
+ raw = ENV[name.to_s]
129
+ return default if raw.nil?
130
+ Float(raw.strip)
131
+ rescue ArgumentError, TypeError
132
+ log_warning("Env.float(#{name.inspect}): could not parse #{raw.inspect} as Float — using default #{default.inspect}")
133
+ default
134
+ end
135
+
136
+ # Read an env var as a String. Returns +default+ when unset.
137
+ # Whitespace is preserved — this is a pass-through for the raw env value,
138
+ # matching Python's Env.str semantics.
139
+ def self.str(name, default: "")
140
+ raw = ENV[name.to_s]
141
+ return default if raw.nil?
142
+ raw
143
+ end
144
+
145
+ # Emit a warning via Tina4::Log without creating a load-order dependency.
146
+ # Mirrors Python's Env._log_warning: silently skip if Log isn't loaded.
147
+ def self.log_warning(message)
148
+ Tina4::Log.warning(message) if defined?(Tina4::Log)
149
+ rescue NameError, StandardError
150
+ # Log not wired up yet (very early bootstrap) — swallow.
151
+ end
152
+ private_class_method :log_warning
153
+
94
154
  class << self
95
155
  def load_env(root_dir = Dir.pwd)
96
156
  env_file = resolve_env_file(root_dir)
data/lib/tina4/log.rb CHANGED
@@ -203,8 +203,10 @@ module Tina4
203
203
  ts = utc_timestamp
204
204
  rid = get_request_id
205
205
  rid_str = rid ? " [#{rid}]" : ""
206
+ fn = caller_name
207
+ fn_str = fn ? " [#{fn}]" : ""
206
208
  ctx = @current_context && !@current_context.empty? ? " #{JSON.generate(@current_context)}" : ""
207
- "#{ts} [#{level_str.ljust(7)}]#{rid_str} #{message}#{ctx}"
209
+ "#{ts} [#{level_str.ljust(7)}]#{rid_str}#{fn_str} #{message}#{ctx}"
208
210
  end
209
211
 
210
212
  def json_line(level, message)
@@ -216,10 +218,46 @@ module Tina4
216
218
  }
217
219
  rid = get_request_id
218
220
  entry[:request_id] = rid if rid
221
+ fn = caller_name
222
+ entry[:function] = fn if fn
219
223
  entry[:context] = @current_context if @current_context && !@current_context.empty?
220
224
  JSON.generate(entry)
221
225
  end
222
226
 
227
+ # Names that belong to Log itself — walk past them so the reported
228
+ # frame is the real caller (e.g. the route handler or service
229
+ # method that called Log.info). Kept as a Set for O(1) lookup.
230
+ OWN_FRAMES = %w[
231
+ caller_name format_line json_line log colorize write_to_file
232
+ debug info warning error critical
233
+ ].freeze
234
+
235
+ # Names that are noise — Ruby block labels, lambdas, top-level
236
+ # script frames. We skip these the same way Python skips <module>
237
+ # and <lambda>.
238
+ NOISE_FRAME_RE = /\A(?:block(?: \(\d+ levels\))? in |<top \(required\)>|<main>)/
239
+
240
+ # Return the name of the function that called Log.{debug,info,warning,error}.
241
+ # Active only when TINA4_LOG_FUNC=true (parity feature #41).
242
+ # Returns nil on any error so it never crashes a log call.
243
+ def caller_name
244
+ return nil unless Tina4::Env.bool("TINA4_LOG_FUNC")
245
+
246
+ # caller_locations(2, 16) skips this method + log() and gives us
247
+ # up to 16 frames to walk. We bail out the moment we hit a frame
248
+ # whose base_label isn't in OWN_FRAMES and isn't a block label.
249
+ locs = caller_locations(2, 16) || []
250
+ locs.each do |loc|
251
+ label = loc.base_label.to_s
252
+ next if OWN_FRAMES.include?(label)
253
+ next if label.empty? || NOISE_FRAME_RE.match?(label)
254
+ return label
255
+ end
256
+ nil
257
+ rescue StandardError
258
+ nil
259
+ end
260
+
223
261
  def colorize(level, line)
224
262
  color = case level
225
263
  when :debug then COLORS[:cyan]
@@ -317,19 +317,43 @@ module Tina4
317
317
  end
318
318
  end
319
319
 
320
- # Execute handler inject path params by name, then request/response
320
+ # Build the route-handler call as a lambda so function-style
321
+ # middleware can wrap it. Path params are still bound by name —
322
+ # the continuation just forwards the (possibly-mutated)
323
+ # request/response pair the outer middleware chose to pass in.
321
324
  handler_params = route.handler.parameters.map(&:last)
322
325
  route_params = path_params || {}
323
- args = handler_params.map do |name|
324
- if route_params.key?(name)
325
- route_params[name]
326
- elsif name == :request || name == :req
327
- request
328
- else
329
- response
326
+ invoke_handler = lambda do |req, resp|
327
+ args = handler_params.map do |name|
328
+ if route_params.key?(name)
329
+ route_params[name]
330
+ elsif name == :request || name == :req
331
+ req
332
+ else
333
+ resp
334
+ end
335
+ end
336
+ args.empty? ? route.handler.call : route.handler.call(*args)
337
+ end
338
+
339
+ # Fold any function-style middleware on this route into a
340
+ # Russian-doll chain wrapping the handler. First declared is the
341
+ # outermost layer — it receives the request first, calls
342
+ # next_handler to descend, and runs its "after" code on the way
343
+ # out. Class-based middleware (before_*/after_*) is handled
344
+ # separately by run_middleware above and never goes through here.
345
+ # tina4-book#141 PY-10-01 (cross-framework parity).
346
+ fn_mws = route.respond_to?(:function_middleware) ? route.function_middleware : []
347
+ if fn_mws.empty?
348
+ result = invoke_handler.call(request, response)
349
+ else
350
+ chain = invoke_handler
351
+ fn_mws.reverse_each do |mw|
352
+ inner = chain
353
+ chain = lambda { |req, resp| mw.call(req, resp, inner) }
330
354
  end
355
+ result = chain.call(request, response)
331
356
  end
332
- result = args.empty? ? route.handler.call : route.handler.call(*args)
333
357
 
334
358
  # Template rendering: when a template is set and the handler returned a Hash,
335
359
  # render the template with the hash as data and return the HTML response.
data/lib/tina4/request.rb CHANGED
@@ -35,6 +35,63 @@ module Tina4
35
35
  end
36
36
  end
37
37
 
38
+ # Hash subclass for HTTP headers — string keys are case-insensitive.
39
+ #
40
+ # HTTP header field-names are case-insensitive per RFC 7230 §3.2. With
41
+ # this class, request.headers["Content-Type"], .headers["content-type"],
42
+ # and .headers["CONTENT-TYPE"] all return the same value. Keys are
43
+ # stored lowercase internally.
44
+ #
45
+ # Cross-framework parity: same behaviour ships in tina4-python
46
+ # (CaseInsensitiveDict), tina4-php (Tina4\Request), tina4-nodejs
47
+ # (Tina4Request). tina4-book#141 PY-10-03 — chapter 10 examples
48
+ # documented headers["Content-Type"] for years; this makes them work.
49
+ class CaseInsensitiveHash < Hash
50
+ def [](key)
51
+ super(normalize_key(key))
52
+ end
53
+
54
+ def []=(key, value)
55
+ super(normalize_key(key), value)
56
+ end
57
+
58
+ def fetch(key, *args, &block)
59
+ super(normalize_key(key), *args, &block)
60
+ end
61
+
62
+ def key?(key)
63
+ super(normalize_key(key))
64
+ end
65
+ alias has_key? key?
66
+ alias include? key?
67
+ alias member? key?
68
+
69
+ def delete(key, &block)
70
+ super(normalize_key(key), &block)
71
+ end
72
+
73
+ def store(key, value)
74
+ super(normalize_key(key), value)
75
+ end
76
+
77
+ def merge(other)
78
+ result = dup
79
+ other.each { |k, v| result[k] = v }
80
+ result
81
+ end
82
+
83
+ def merge!(other)
84
+ other.each { |k, v| self[k] = v }
85
+ self
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_key(key)
91
+ key.is_a?(String) ? key.downcase : key
92
+ end
93
+ end
94
+
38
95
  class Request
39
96
  attr_reader :env, :method, :path, :query_string, :content_type,
40
97
  :path_params, :ip
@@ -139,7 +196,10 @@ module Tina4
139
196
  end
140
197
 
141
198
  def header(name)
142
- headers[name.to_s.downcase.gsub("-", "_")]
199
+ # Headers are stored in a CaseInsensitiveHash keyed by lowercase-
200
+ # dashed names ("content-type", "x-api-key"). The hash normalises
201
+ # the lookup case automatically, so pass the dashed form through.
202
+ headers[name.to_s.tr("_", "-")]
143
203
  end
144
204
 
145
205
  def json_body
@@ -169,12 +229,22 @@ module Tina4
169
229
  end
170
230
 
171
231
  def extract_headers
172
- h = {}
232
+ h = CaseInsensitiveHash.new
173
233
  @env.each do |key, value|
174
234
  if key.start_with?("HTTP_")
175
- h[key[5..-1].downcase] = value
235
+ # Rack normalises "Content-Type" to "HTTP_CONTENT_TYPE" — we
236
+ # store as "content-type" (lowercase, dashed) so both
237
+ # headers["Content-Type"] and the legacy headers["content_type"]
238
+ # via the `header()` helper resolve to the same value. The
239
+ # CaseInsensitiveHash handles the case-insensitive part.
240
+ h[key[5..-1].tr("_", "-").downcase] = value
176
241
  end
177
242
  end
243
+ # CONTENT_TYPE / CONTENT_LENGTH live at the top level of the Rack
244
+ # env (no HTTP_ prefix per the Rack spec) — surface them too so
245
+ # request.headers["Content-Type"] works for POST bodies.
246
+ h["content-type"] = @env["CONTENT_TYPE"] if @env["CONTENT_TYPE"] && !@env["CONTENT_TYPE"].empty?
247
+ h["content-length"] = @env["CONTENT_LENGTH"] if @env["CONTENT_LENGTH"] && !@env["CONTENT_LENGTH"].to_s.empty?
178
248
  h
179
249
  end
180
250
 
data/lib/tina4/router.rb CHANGED
@@ -17,9 +17,11 @@ module Tina4
17
17
  @swagger_meta = swagger_meta
18
18
  @middleware = middleware.freeze
19
19
  @template = template&.freeze
20
- # Write routes are secure by default, unless custom middleware is registered
21
- # (developer handles auth themselves via middleware)
22
- @auth_required = %w[POST PUT PATCH DELETE].include?(@method) && middleware.empty?
20
+ # Write routes are secure by default bearer-token auth is enforced
21
+ # on POST/PUT/PATCH/DELETE regardless of attached middleware.
22
+ # Middleware is additive, never an auth bypass. tina4-book#141
23
+ # PY-10-02. Call .no_auth on the route to opt out explicitly.
24
+ @auth_required = %w[POST PUT PATCH DELETE].include?(@method)
23
25
  @cached = false
24
26
  @param_names = []
25
27
  @path_regex = compile_pattern(@path)
@@ -50,13 +52,18 @@ module Tina4
50
52
  # Dual-mode: getter (no args) returns the middleware array;
51
53
  # setter (with args) appends middleware and returns self for chaining.
52
54
  # Router.post("/api") { ... }.middleware(AuthMiddleware)
55
+ #
56
+ # Middleware is purely additive — registering middleware NEVER flips
57
+ # @auth_required off. The secure-by-default gate for write methods
58
+ # (POST/PUT/PATCH/DELETE) stays in effect; if a route truly wants to
59
+ # opt out of the built-in bearer check, call .no_auth explicitly.
60
+ # tina4-book#141 PY-10-02 — previously, attaching ANY middleware
61
+ # silently turned off auth_required, which let attackers bypass auth
62
+ # by routing through a logging middleware. Cross-framework parity.
53
63
  def middleware(*middleware_classes)
54
64
  return @middleware if middleware_classes.empty?
55
65
 
56
66
  @middleware = @middleware.dup + middleware_classes
57
- # Custom middleware means developer handles auth — disable built-in gate
58
- # unless .secure was explicitly called.
59
- @auth_required = false unless @auth_required
60
67
  self
61
68
  end
62
69
 
@@ -83,15 +90,50 @@ module Tina4
83
90
  end
84
91
  end
85
92
 
86
- # Run per-route middleware chain; returns true if all pass
93
+ # Run per-route CLASS-based middleware. Function-style middleware
94
+ # (Proc/Lambda taking 3+ args: req, resp, next_handler) is skipped
95
+ # here — it's dispatched separately via #function_middleware which
96
+ # wraps the route handler in a continuation chain (see rack_app.rb).
97
+ #
98
+ # Returns true if all class-based middleware passed, false if any
99
+ # returned literal false (halt request).
87
100
  def run_middleware(request, response)
88
101
  @middleware.each do |mw|
102
+ next if Route.function_middleware?(mw)
89
103
  result = mw.call(request, response)
90
104
  return false if result == false
91
105
  end
92
106
  true
93
107
  end
94
108
 
109
+ # Function-style middleware attached to this route, in declaration
110
+ # order. The route dispatcher folds them into a Russian-doll
111
+ # continuation chain — first declared is the OUTERMOST layer (runs
112
+ # first on the way in, last on the way out). tina4-book#141
113
+ # PY-10-01 — chapter 10 documented 8+ examples of function middleware
114
+ # for years; before this fix the framework silently ignored them.
115
+ def function_middleware
116
+ @middleware.select { |mw| Route.function_middleware?(mw) }
117
+ end
118
+
119
+ # Detect Express/FastAPI-style function middleware.
120
+ #
121
+ # A Proc/Lambda/Method whose arity indicates 3+ positional params
122
+ # (req, resp, next_handler). Ruby arity quirk: required-args-only
123
+ # arity is non-negative; if the callable accepts a splat or
124
+ # optionals, arity is negated (-required-1). We treat arity >= 3
125
+ # OR arity <= -4 as function-style. Anything else (a class with
126
+ # before_*/after_* methods, or a 2-arg callable) is treated as
127
+ # class-style and goes through #run_middleware.
128
+ def self.function_middleware?(mw)
129
+ return false if mw.is_a?(Class) || mw.is_a?(Module)
130
+ return false unless mw.respond_to?(:arity)
131
+ ar = mw.arity
132
+ ar >= 3 || ar <= -4
133
+ rescue StandardError
134
+ false
135
+ end
136
+
95
137
  private
96
138
 
97
139
  def normalize_path(path)
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.2"
4
+ VERSION = "3.13.4"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.2
4
+ version: 3.13.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-03 00:00:00.000000000 Z
11
+ date: 2026-06-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack