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 +4 -4
- data/lib/tina4/env.rb +60 -0
- data/lib/tina4/log.rb +39 -1
- data/lib/tina4/rack_app.rb +33 -9
- data/lib/tina4/request.rb +73 -3
- data/lib/tina4/router.rb +49 -7
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f850a40f190c65782dd6f995b210335fa8b5c230c241b7d7031e34c0118f5d9e
|
|
4
|
+
data.tar.gz: 15a8e7d8cfdadd898917d1604d64cfd3f84dcc83838f5c4bf2a7f2c7ec66df3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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]
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -317,19 +317,43 @@ module Tina4
|
|
|
317
317
|
end
|
|
318
318
|
end
|
|
319
319
|
|
|
320
|
-
#
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
route_params
|
|
326
|
-
|
|
327
|
-
request
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
#
|
|
22
|
-
|
|
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
|
|
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
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.
|
|
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-
|
|
11
|
+
date: 2026-06-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|